wireweave 0.3.45 → 0.3.47

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wireweave",
3
- "version": "0.3.45",
3
+ "version": "0.3.47",
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
- if (parsed.ns === 'ban' && pubkey && !data.banned.includes(pubkey)) data.banned.push(pubkey);
73
- else if (parsed.ns === 'kick' && pubkey) {
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.onmessage = (e) => this._emit('data', { peerPubkey, data: e.data });
260
- dc.onerror = (e) => this._emit('peer-error', { peerPubkey, error: e });
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/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
@@ -64,7 +64,15 @@ class RelayHealth {
64
64
  this.rank = 50;
65
65
  }
66
66
 
67
- recordConnectAttempt() { this.attempts++; }
67
+ // Recomputes rank on every attempt, not just on a successful signal —
68
+ // otherwise a relay that NEVER once connects (rank frozen at the ctor's
69
+ // neutral 50 forever, since recordConnectLatency/recordSustainedConnection/
70
+ // recordEoseLatency — the only other rank-recomputing call sites — never
71
+ // fire for it) reads as perpetually "average" no matter how many failed
72
+ // attempts pile up, defeating both healthReport() ranking and
73
+ // _maybeRotate's rank-gap rotation trigger for the exact "never connects
74
+ // at all" relay auto-rotation exists to route around.
75
+ recordConnectAttempt() { this.attempts++; this.rank = computeRank(this); }
68
76
 
69
77
  recordConnectLatency(ms) {
70
78
  this.connectLatencyMs = ewma(this.connectLatencyMs, ms);
@@ -118,8 +126,55 @@ const fnv1a = (s) => {
118
126
 
119
127
  const safeSubId = (subId) => subId.length <= 64 ? subId : subId.slice(0, 55) + '-' + fnv1a(subId);
120
128
 
129
+ // Shared publish budget: a real token bucket, refilled continuously at
130
+ // refillPerSec tokens/sec up to burstCap, drained one token per publish()
131
+ // call. This is the SINGLE choke point every module's writes go through
132
+ // (chat, dm, bans, roles, settings, servers, data-channel signaling all
133
+ // call pool.publish()) so one shared bucket per RelayPool instance budgets
134
+ // abuse across all of them at once, rather than each module needing (and
135
+ // forgetting) its own independent limiter — chat.js's own 5-per-10s limiter
136
+ // stays as an app-level UX throttle (rate-limited event + retryAfterMs for
137
+ // a chat input box), this is the lower-level protocol-wide backstop.
138
+ const DEFAULT_BUDGET_BURST = 30;
139
+ const DEFAULT_BUDGET_REFILL_PER_SEC = 3;
140
+
141
+ class PublishBudget {
142
+ constructor({ burstCap = DEFAULT_BUDGET_BURST, refillPerSec = DEFAULT_BUDGET_REFILL_PER_SEC } = {}) {
143
+ this.burstCap = burstCap;
144
+ this.refillPerSec = refillPerSec;
145
+ this.tokens = burstCap;
146
+ this.lastRefillAt = Date.now();
147
+ }
148
+
149
+ _refill() {
150
+ const now = Date.now();
151
+ const elapsedSec = (now - this.lastRefillAt) / 1000;
152
+ if (elapsedSec <= 0) return;
153
+ this.tokens = Math.min(this.burstCap, this.tokens + elapsedSec * this.refillPerSec);
154
+ this.lastRefillAt = now;
155
+ }
156
+
157
+ // Returns true and consumes a token if available, else false (caller
158
+ // decides what "budget exceeded" means — RelayPool.publish() below
159
+ // queues the event as pending rather than dropping it, since a
160
+ // rate-limited-not-lost event still eventually goes out once the bucket
161
+ // refills, via the same _drainPending path a disconnected relay uses).
162
+ tryConsume() {
163
+ this._refill();
164
+ if (this.tokens < 1) return false;
165
+ this.tokens -= 1;
166
+ return true;
167
+ }
168
+
169
+ retryAfterMs() {
170
+ this._refill();
171
+ if (this.tokens >= 1) return 0;
172
+ return Math.ceil((1 - this.tokens) / this.refillPerSec * 1000);
173
+ }
174
+ }
175
+
121
176
  export class RelayPool extends EventTarget {
122
- constructor({ relays = DEFAULT_RELAYS, verifyEvent = null, WebSocketImpl = null, storage = null, fallbackRelays = FALLBACK_RELAYS, autoRotate = true } = {}) {
177
+ constructor({ relays = DEFAULT_RELAYS, verifyEvent = null, WebSocketImpl = null, storage = null, fallbackRelays = FALLBACK_RELAYS, autoRotate = true, publishBudget = {} } = {}) {
123
178
  super();
124
179
  this.urls = [...relays];
125
180
  this.relays = new Map();
@@ -141,6 +196,10 @@ export class RelayPool extends EventTarget {
141
196
  for (const url of this.urls) this.health.set(url, new RelayHealth(url));
142
197
  this._loadHealth();
143
198
  this._saveHealthTimer = null;
199
+ // publishBudget:false disables the budget entirely (unbounded, the old
200
+ // behavior) — anything else (including {}) gets a real token bucket.
201
+ this.budget = publishBudget === false ? null : new PublishBudget(publishBudget);
202
+ this._budgetDrainTimer = null;
144
203
 
145
204
  // Debug-panel integration: window.__wireweave.relayPool (or relayPool2,
146
205
  // relayPool3... for additional instances) exposes healthReport() live.
@@ -199,7 +258,13 @@ export class RelayPool extends EventTarget {
199
258
  // Swap the worst currently-active relay for the best-ranked unused
200
259
  // candidate (fallback pool, or a previously-tried relay we disconnected
201
260
  // from) when the gap is large enough to be worth the churn of a new
202
- // connection. Never rotates below MIN_ACTIVE_RELAYS active URLs.
261
+ // connection. Never rotates below MIN_ACTIVE_RELAYS active URLs. Called
262
+ // from every ws.onclose (both the sustained-then-dropped branch AND the
263
+ // never-connected/failed-fast branch) — a relay that never manages to
264
+ // connect at all still needs evaluating here, since that IS the
265
+ // "consistently unhealthy" case rotation exists to route around, and its
266
+ // rank already reflects the failures via recordConnectAttempt's own
267
+ // computeRank call once attempts >= 2 (the sample floor enforced below).
203
268
  _maybeRotate() {
204
269
  if (!this.autoRotate || this._closed) return;
205
270
  const MIN_ACTIVE_RELAYS = 2;
@@ -254,6 +319,7 @@ export class RelayPool extends EventTarget {
254
319
  this._closed = true;
255
320
  for (const [, t] of this._reconnectTimers) clearTimeout(t);
256
321
  this._reconnectTimers.clear();
322
+ if (this._budgetDrainTimer) { clearTimeout(this._budgetDrainTimer); this._budgetDrainTimer = null; }
257
323
  for (const [, rec] of this._acks) { clearTimeout(rec.timer); rec.resolve(false); }
258
324
  this._acks.clear();
259
325
  for (const [, r] of this.relays) {
@@ -326,11 +392,17 @@ export class RelayPool extends EventTarget {
326
392
  relay.reconnectDelay = 1000;
327
393
  health.recordSustainedConnection();
328
394
  this._scheduleSaveHealth();
329
- this._maybeRotate();
330
395
  } else {
331
396
  relay.failCount++;
332
397
  relay.reconnectDelay = Math.min(relay.reconnectDelay * 2, 30000);
333
398
  }
399
+ // Evaluate rotation on EVERY close, not just a sustained-then-dropped
400
+ // connection — a relay that never manages to connect at all (the
401
+ // `else` branch above) is exactly the "consistently unhealthy"
402
+ // relay auto-rotation exists to route around, and its rank already
403
+ // reflects that via computeRank's uptime/latency components once
404
+ // `attempts >= 2` (the sample floor _maybeRotate itself enforces).
405
+ this._maybeRotate();
334
406
  relay._openedAt = null;
335
407
  if (this._closed) return;
336
408
  const t = setTimeout(() => this._open(url), jitter(relay.reconnectDelay));
@@ -401,7 +473,21 @@ export class RelayPool extends EventTarget {
401
473
  this.subs.delete(subId);
402
474
  }
403
475
 
476
+ // Budget-gated: over-budget calls are not dropped, they're queued exactly
477
+ // like a disconnected-relay event (via _queuePending) and flushed by the
478
+ // normal _drainPending path once either a relay reconnects or, for a
479
+ // budget-only rejection, the next successful publish()/heal() drains the
480
+ // backlog opportunistically. This means a caller that publishes faster
481
+ // than the budget allows sees eventual delivery, not silent loss — the
482
+ // same "fire-and-forget with delivery confidence via publishAndWait()"
483
+ // contract the rest of this class already documents.
404
484
  publish(event) {
485
+ if (this.budget && !this.budget.tryConsume()) {
486
+ this._emit('rate-limited', { retryAfterMs: this.budget.retryAfterMs() });
487
+ this._queuePending(event, new Set());
488
+ this._scheduleBudgetDrain();
489
+ return false;
490
+ }
405
491
  let sent = false;
406
492
  let anyDisconnected = false;
407
493
  const sentTo = new Set();
@@ -419,6 +505,38 @@ export class RelayPool extends EventTarget {
419
505
  return sent;
420
506
  }
421
507
 
508
+ // Live budget introspection for a debug panel / caller backoff decision.
509
+ budgetStatus() {
510
+ if (!this.budget) return { enabled: false };
511
+ this.budget._refill();
512
+ return { enabled: true, tokens: this.budget.tokens, burstCap: this.budget.burstCap, refillPerSec: this.budget.refillPerSec, retryAfterMs: this.budget.retryAfterMs() };
513
+ }
514
+
515
+ // A budget-rejected publish() queues into `this.pending` exactly like a
516
+ // disconnected-relay event, but `this.pending` otherwise only drains on
517
+ // ws.onopen (a relay reconnecting) — if every relay stays connected the
518
+ // whole time, a budget-queued event needs its OWN retry path once tokens
519
+ // refill, or it would sit queued until PENDING_TTL_MS expiry and get
520
+ // silently dropped despite the relay connection being perfectly healthy.
521
+ // One timer, scheduled only while budget-queued events are outstanding
522
+ // (never a standing interval), retries a real drain against every
523
+ // currently-open relay once the bucket should have refilled a token.
524
+ _scheduleBudgetDrain() {
525
+ if (this._budgetDrainTimer || !this.budget || this._closed) return;
526
+ const delay = Math.max(50, this.budget.retryAfterMs());
527
+ this._budgetDrainTimer = setTimeout(() => {
528
+ this._budgetDrainTimer = null;
529
+ if (this._closed) return;
530
+ let anyOpen = false;
531
+ for (const [url, relay] of this.relays) {
532
+ if (relay.ws?.readyState === 1) { anyOpen = true; this._drainPending(url, relay.ws); }
533
+ }
534
+ // Still budget-limited or nothing connected yet — keep retrying as
535
+ // long as there's a real backlog, so it isn't stranded until TTL.
536
+ if (this.pending.length > 0 && (anyOpen || this.budget.retryAfterMs() > 0)) this._scheduleBudgetDrain();
537
+ }, delay);
538
+ }
539
+
422
540
  // Tracks delivery per relay URL, not just "sent to at least one" — a relay
423
541
  // mid-reconnect during a partial outage otherwise never gets the event.
424
542
  _queuePending(event, sentTo) {