wireweave 0.3.20 → 0.3.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -27,10 +27,26 @@ ww.auth.loadFromStorage() || ww.auth.generateKey();
27
27
  ww.servers.init();
28
28
  ```
29
29
 
30
- `ww` exposes: `pool`, `auth`, `fsm`, `message`, `bans`, `roles`, `settings`, `pages`, `media`, `channels`, `servers`, `chat`, `voice` (lazy via `ensureVoice()`), `setCurrentChannel()`.
30
+ `ww` exposes: `pool`, `auth`, `fsm`, `message`, `bans`, `roles`, `settings`, `pages`, `media`, `channels`, `servers`, `chat`, `voice` (lazy via `ensureVoice()`), `dm` (lazy via `ensureDM()`), `setCurrentChannel()`, `currentChannelId`, `currentServerId`.
31
31
 
32
32
  every submodule is an `EventTarget`. subscribe with `addEventListener('event', ...)`.
33
33
 
34
+ `message` is a standalone in-memory `MessageBus` (bounded ring + typed handler dispatch). It is provided for apps that want a local message store; no other wireweave module depends on it, so ignore it if you don't need it.
35
+
36
+ ### node / non-browser environments
37
+
38
+ `createWireweave` and `NostrAuth`/`Servers` need a `storage` adapter outside the browser — pass `{ getItem, setItem, removeItem }`. Without it `createWireweave` throws `wireweave: storage required` up front (in the browser it defaults to `localStorage`). On-relay `d`-tags use a frozen `zellous-` prefix and storage keys use `zn_*`; these are published wire/storage contracts kept stable across the rename to wireweave — do not change them without a migration path.
39
+
40
+ ## direct messages (encrypted)
41
+
42
+ ```js
43
+ const dm = ww.ensureDM(); // lazy: needs nostr-tools built with nip44
44
+ dm.subscribe(({ peer, plaintext }) => console.log(peer, plaintext));
45
+ await dm.send(peerPubkey, 'hello');
46
+ ```
47
+
48
+ **Caveat:** nip44 encryption derives a conversation key from your **private key**, so DM requires a privkey-backed signer (`generateKey`/`importKey`). It does **not** work with extension signing (NIP-07), which never exposes the privkey — `dm.send` throws `DM: privkey required` in that case.
49
+
34
50
  ## modules
35
51
 
36
52
  ```js
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "wireweave",
3
- "version": "0.3.20",
3
+ "version": "0.3.22",
4
4
  "description": "nostr + webrtc voice + data SDK. networking layer for 247420 projects.",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/bans.js CHANGED
@@ -6,10 +6,11 @@ export class Bans extends EventTarget {
6
6
  if (!relayPool) throw new Error('Bans: relayPool required');
7
7
  this.pool = relayPool; this.auth = auth; this.roles = roles;
8
8
  this.store = new Map();
9
- this.sub = null;
9
+ this.subs = new Map();
10
10
  }
11
11
 
12
12
  isBanned(serverId, pubkey) { return !!(this.store.get(serverId)?.banned || []).includes(pubkey); }
13
+ isKicked(serverId, pubkey) { return !!(this.store.get(serverId)?.kicked || []).includes(pubkey); }
13
14
 
14
15
  isTimedOut(serverId, pubkey) {
15
16
  const t = this.store.get(serverId)?.timeouts?.[pubkey];
@@ -51,12 +52,13 @@ export class Bans extends EventTarget {
51
52
  }
52
53
 
53
54
  subscribe(serverId) {
54
- if (this.sub) { this.pool.unsubscribe(this.sub); this.sub = null; }
55
+ if (this.subs.has(serverId)) return;
55
56
  if (!serverId) return;
56
57
  const creator = serverId.split(':')[0];
57
58
  if (!creator) return;
58
- this.sub = 'bans-' + serverId;
59
- this.pool.subscribe(this.sub,
59
+ const subId = 'bans-' + serverId;
60
+ this.subs.set(serverId, subId);
61
+ this.pool.subscribe(subId,
60
62
  [{ kinds: [30078], authors: [creator], '#server': [serverId] }],
61
63
  (event) => {
62
64
  if (event.pubkey !== creator) return;
@@ -64,13 +66,16 @@ export class Bans extends EventTarget {
64
66
  const dTag = event.tags.find(t => t[0] === 'd');
65
67
  if (!dTag?.[1]) return;
66
68
  const parsed = parseDtag(dTag[1]);
67
- if (!parsed || (parsed.ns !== 'ban' && parsed.ns !== 'timeout')) return;
69
+ if (!parsed || !['ban', 'timeout', 'kick'].includes(parsed.ns)) return;
68
70
  const pubkey = parsed.parts[parsed.parts.length - 1];
69
- const data = this.store.get(serverId) || { banned: [], timeouts: {} };
71
+ const data = this.store.get(serverId) || { banned: [], timeouts: {}, kicked: [] };
70
72
  if (parsed.ns === 'ban' && pubkey && !data.banned.includes(pubkey)) data.banned.push(pubkey);
71
- else if (parsed.ns === 'timeout' && pubkey) {
72
- const parsed = JSON.parse(event.content);
73
- if (parsed.expiry > Math.floor(Date.now() / 1000)) (data.timeouts = data.timeouts || {})[pubkey] = { expiry: parsed.expiry };
73
+ else if (parsed.ns === 'kick' && pubkey) {
74
+ data.kicked = data.kicked || [];
75
+ if (!data.kicked.includes(pubkey)) data.kicked.push(pubkey);
76
+ } else if (parsed.ns === 'timeout' && pubkey) {
77
+ const body = JSON.parse(event.content);
78
+ if (body.expiry > Math.floor(Date.now() / 1000)) (data.timeouts = data.timeouts || {})[pubkey] = { expiry: body.expiry };
74
79
  else if (data.timeouts?.[pubkey]) delete data.timeouts[pubkey];
75
80
  }
76
81
  this.store.set(serverId, data);
@@ -78,6 +83,11 @@ export class Bans extends EventTarget {
78
83
  } catch {}
79
84
  });
80
85
  }
86
+
87
+ unsubscribe(serverId) {
88
+ const subId = this.subs.get(serverId);
89
+ if (subId) { this.pool.unsubscribe(subId); this.subs.delete(serverId); }
90
+ }
81
91
  }
82
92
 
83
93
  export const createBans = (opts) => new Bans(opts);
package/src/channels.js CHANGED
@@ -19,9 +19,10 @@ export class Channels extends EventTarget {
19
19
  this.serverId = ''; this.channels = []; this.categories = [];
20
20
  }
21
21
 
22
- isOwner() { return this.auth.pubkey && this.serverId && this.auth.pubkey === this.serverId.split(':')[0]; }
22
+ isOwner() { return !!(this.auth.pubkey && this.serverId && this.auth.pubkey === this.serverId.split(':')[0]); }
23
23
 
24
24
  load(serverId, onReady) {
25
+ if (this.serverId) this.pool.unsubscribe('channels-' + this.serverId);
25
26
  this.serverId = serverId;
26
27
  this.channels = []; this.categories = [];
27
28
  const ownerPubkey = serverId.split(':')[0];
@@ -62,43 +63,60 @@ export class Channels extends EventTarget {
62
63
  }
63
64
 
64
65
  async create(name, type = 'text', categoryId = 'general') {
65
- this.channels = [...this.channels, { id: 'ch-' + Date.now(), name, type, categoryId, position: this.channels.length }];
66
+ if (!this.isOwner()) throw new Error('owner only');
67
+ this.channels = [...this.channels, { id: 'ch-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), name, type, categoryId, position: this.channels.length }];
66
68
  await this._publish();
67
69
  this._emit('updated', { channels: this.channels, categories: this.categories });
68
70
  }
69
71
 
70
72
  async rename(id, name) {
73
+ if (!this.isOwner()) throw new Error('owner only');
71
74
  this.channels = this.channels.map(c => c.id === id ? { ...c, name } : c);
72
75
  await this._publish(); this._emit('updated', { channels: this.channels, categories: this.categories });
73
76
  }
74
77
 
78
+ // Per-channel metadata patch — used for topic, voiceMode, and any future
79
+ // server-published channel-scoped configuration. Owner-only.
80
+ async update(id, patch) {
81
+ if (!this.isOwner()) throw new Error('owner only');
82
+ if (!patch || typeof patch !== 'object') return;
83
+ this.channels = this.channels.map(c => c.id === id ? { ...c, ...patch } : c);
84
+ await this._publish(); this._emit('updated', { channels: this.channels, categories: this.categories });
85
+ }
86
+
75
87
  async remove(id) {
88
+ if (!this.isOwner()) throw new Error('owner only');
76
89
  this.channels = this.channels.filter(c => c.id !== id);
77
90
  await this._publish(); this._emit('updated', { channels: this.channels, categories: this.categories });
78
91
  }
79
92
 
80
93
  async createCategory(name) {
81
- this.categories = [...this.categories, { id: 'cat-' + Date.now(), name, position: this.categories.length }];
94
+ if (!this.isOwner()) throw new Error('owner only');
95
+ this.categories = [...this.categories, { id: 'cat-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6), name, position: this.categories.length }];
82
96
  await this._publish(); this._emit('updated', { channels: this.channels, categories: this.categories });
83
97
  }
84
98
 
85
99
  async renameCategory(id, name) {
100
+ if (!this.isOwner()) throw new Error('owner only');
86
101
  this.categories = this.categories.map(c => c.id === id ? { ...c, name } : c);
87
102
  await this._publish(); this._emit('updated', { channels: this.channels, categories: this.categories });
88
103
  }
89
104
 
90
105
  async deleteCategory(id) {
106
+ if (!this.isOwner()) throw new Error('owner only');
91
107
  this.categories = this.categories.filter(c => c.id !== id);
92
108
  this.channels = this.channels.map(c => c.categoryId === id ? { ...c, categoryId: null } : c);
93
109
  await this._publish(); this._emit('updated', { channels: this.channels, categories: this.categories });
94
110
  }
95
111
 
96
112
  async reorder(catId, ids) {
113
+ if (!this.isOwner()) throw new Error('owner only');
97
114
  ids.forEach((chId, idx) => { this.channels = this.channels.map(c => c.id === chId ? { ...c, position: idx, categoryId: catId } : c); });
98
115
  await this._publish(); this._emit('updated', { channels: this.channels, categories: this.categories });
99
116
  }
100
117
 
101
118
  async reorderCategories(ids) {
119
+ if (!this.isOwner()) throw new Error('owner only');
102
120
  ids.forEach((catId, idx) => { this.categories = this.categories.map(c => c.id === catId ? { ...c, position: idx } : c); });
103
121
  await this._publish(); this._emit('updated', { channels: this.channels, categories: this.categories });
104
122
  }
package/src/chat.js CHANGED
@@ -52,6 +52,11 @@ export class Chat extends EventTarget {
52
52
  }
53
53
 
54
54
  async deleteMessage(id) {
55
+ const msg = this.messages.find(m => m.id === id);
56
+ if (!msg) return;
57
+ const { serverId } = this.getChannelContext();
58
+ const isAuthor = msg.userId === this.auth.pubkey;
59
+ if (!isAuthor && !this.isAdmin(serverId)) throw new Error('Cannot delete: not author or admin');
55
60
  const signed = await this.auth.sign({ kind: 5, created_at: Math.floor(Date.now() / 1000), tags: [['e', id]], content: 'deleted' });
56
61
  this.pool.publish(signed);
57
62
  this.messages = this.messages.filter(m => m.id !== id);
package/src/data.js CHANGED
@@ -34,7 +34,8 @@ export class DataSession extends EventTarget {
34
34
  }
35
35
 
36
36
  _initActor() {
37
- const machine = this.fsm.dataMachine || this.fsm.voiceMachine;
37
+ const machine = this.fsm.dataMachine;
38
+ if (!machine) throw new Error('DataSession: fsm.dataMachine missing');
38
39
  this.actor = this.xstate.createActor(machine);
39
40
  this.actor.subscribe((snap) => this.dispatchEvent(new CustomEvent('state', { detail: { value: snap.value } })));
40
41
  this.actor.start();
@@ -72,6 +73,7 @@ export class DataSession extends EventTarget {
72
73
  this._stopHeartbeat();
73
74
  for (const pk of Array.from(this.peers.keys())) this._closePeer(pk);
74
75
  this.peers.clear();
76
+ for (const pk of Object.keys(this.retrySchedule)) this._cancelReconnect(pk);
75
77
  if (this.roomId) {
76
78
  this.pool.unsubscribe('data-presence-' + this.roomId);
77
79
  this.pool.unsubscribe('data-signals-' + this.roomId);
package/src/dtag.js CHANGED
@@ -1,13 +1,19 @@
1
1
  const NAMESPACES = new Set(['ban', 'timeout', 'kick', 'page', 'channels', 'roles', 'settings']);
2
2
 
3
+ // Frozen on-relay wire prefix. The brand is now wireweave but deployed events
4
+ // carry 'zellous-' d-tags, so this is a published-contract constant — do not
5
+ // rename it without a migration path. parseDtag derives its slice offset from
6
+ // PREFIX.length so the literal and the offset can never drift apart.
7
+ const PREFIX = 'zellous-';
8
+
3
9
  export const dtag = (ns, ...parts) => {
4
10
  if (!NAMESPACES.has(ns)) throw new Error('dtag: unknown namespace ' + ns);
5
- return ['zellous-' + ns, ...parts].join(':');
11
+ return [PREFIX + ns, ...parts].join(':');
6
12
  };
7
13
 
8
14
  export const parseDtag = (s) => {
9
- if (typeof s !== 'string' || !s.startsWith('zellous-')) return null;
10
- const parts = s.slice(8).split(':');
15
+ if (typeof s !== 'string' || !s.startsWith(PREFIX)) return null;
16
+ const parts = s.slice(PREFIX.length).split(':');
11
17
  const ns = parts.shift();
12
18
  if (!NAMESPACES.has(ns)) return null;
13
19
  return { ns, parts };
package/src/message.js CHANGED
@@ -10,7 +10,7 @@ export class MessageBus extends EventTarget {
10
10
  register(type, fn) { this.handlers[type] = fn; }
11
11
 
12
12
  add(text, { audioData = null, userId = null, username = null } = {}) {
13
- const msg = { id: Date.now() + Math.random(), text, time: Date.now(), userId, username, audioData };
13
+ const msg = { id: Date.now().toString(36) + Math.random().toString(36).slice(2), text, time: Date.now(), userId, username, audioData };
14
14
  this.messages = [...this.messages, msg];
15
15
  if (this.messages.length > this.max) this.messages = this.messages.slice(-this.max);
16
16
  this.dispatchEvent(new CustomEvent('message', { detail: msg }));
package/src/roles.js CHANGED
@@ -6,7 +6,7 @@ export class Roles extends EventTarget {
6
6
  if (!relayPool || !auth) throw new Error('Roles: deps required');
7
7
  this.pool = relayPool; this.auth = auth;
8
8
  this.store = new Map();
9
- this.sub = null;
9
+ this.subs = new Map();
10
10
  }
11
11
 
12
12
  _creatorOf(serverId) { return serverId ? serverId.split(':')[0] : null; }
@@ -39,12 +39,13 @@ export class Roles extends EventTarget {
39
39
  }
40
40
 
41
41
  subscribe(serverId) {
42
- if (this.sub) { this.pool.unsubscribe(this.sub); this.sub = null; }
42
+ if (this.subs.has(serverId)) return;
43
43
  if (!serverId) return;
44
44
  const creator = this._creatorOf(serverId);
45
45
  if (!creator) return;
46
- this.sub = 'roles-' + serverId;
47
- this.pool.subscribe(this.sub,
46
+ const subId = 'roles-' + serverId;
47
+ this.subs.set(serverId, subId);
48
+ this.pool.subscribe(subId,
48
49
  [{ kinds: [30078], authors: [creator], '#d': [dtag('roles', serverId)] }],
49
50
  (event) => {
50
51
  if (event.pubkey !== creator) return;
@@ -55,6 +56,11 @@ export class Roles extends EventTarget {
55
56
  } catch {}
56
57
  });
57
58
  }
59
+
60
+ unsubscribe(serverId) {
61
+ const subId = this.subs.get(serverId);
62
+ if (subId) { this.pool.unsubscribe(subId); this.subs.delete(serverId); }
63
+ }
58
64
  }
59
65
 
60
66
  export const createRoles = (opts) => new Roles(opts);
package/src/settings.js CHANGED
@@ -9,7 +9,7 @@ export class Settings extends EventTarget {
9
9
  if (!relayPool || !auth || !roles) throw new Error('Settings: deps required');
10
10
  this.pool = relayPool; this.auth = auth; this.roles = roles;
11
11
  this.store = new Map();
12
- this.sub = null;
12
+ this.subs = new Map();
13
13
  }
14
14
 
15
15
  getBitrate(serverId) { return this.store.get(serverId)?.opusBitrate || 24000; }
@@ -57,18 +57,24 @@ export class Settings extends EventTarget {
57
57
  }
58
58
 
59
59
  subscribe(serverId) {
60
- if (this.sub) { this.pool.unsubscribe(this.sub); this.sub = null; }
60
+ if (this.subs.has(serverId)) return;
61
61
  if (!serverId) return;
62
62
  const creator = serverId.split(':')[0];
63
63
  if (!creator) return;
64
- this.sub = 'settings-' + serverId;
65
- this.pool.subscribe(this.sub,
64
+ const subId = 'settings-' + serverId;
65
+ this.subs.set(serverId, subId);
66
+ this.pool.subscribe(subId,
66
67
  [{ kinds: [30078], authors: [creator], '#d': [dtag('settings', serverId)] }],
67
68
  (event) => {
68
69
  if (event.pubkey !== creator) return;
69
70
  try { this.store.set(serverId, JSON.parse(event.content)); this.dispatchEvent(new CustomEvent('updated', { detail: { serverId, next: this.store.get(serverId) } })); } catch {}
70
71
  });
71
72
  }
73
+
74
+ unsubscribe(serverId) {
75
+ const subId = this.subs.get(serverId);
76
+ if (subId) { this.pool.unsubscribe(subId); this.subs.delete(serverId); }
77
+ }
72
78
  }
73
79
 
74
80
  export const createSettings = (opts) => new Settings(opts);
package/src/voice.js CHANGED
@@ -103,6 +103,7 @@ export class VoiceSession extends EventTarget {
103
103
  this._sfuStop();
104
104
  for (const pk of Array.from(this.peers.keys())) this._closePeer(pk);
105
105
  this.peers.clear();
106
+ for (const pk of Object.keys(this.retrySchedule)) this._cancelReconnect(pk);
106
107
  if (this._activityTimer) { clearInterval(this._activityTimer); this._activityTimer = null; }
107
108
  if (this._activeAnalyzers) { for (const k of Array.from(this._activeAnalyzers.keys())) this._detachAnalyzer(k); }
108
109
  if (this._outboundRec) { try { this._outboundRec.rec.stop(); } catch {} this._outboundRec = null; }
package/src/wireweave.js CHANGED
@@ -11,6 +11,7 @@ import { createRoles } from './roles.js';
11
11
  import { createSettings } from './settings.js';
12
12
  import { createMedia } from './media.js';
13
13
  import { createPages } from './pages.js';
14
+ import { createDM } from './dm.js';
14
15
  import { register } from './debug.js';
15
16
 
16
17
  export const createWireweave = ({
@@ -76,10 +77,21 @@ export const createWireweave = ({
76
77
 
77
78
  const setCurrentChannel = (id) => { currentChannelId = id; if (id) chat.loadHistory(id); };
78
79
 
80
+ // DM is lazy: nip44 encryption requires a privkey-backed signer (not extension)
81
+ // and nostr-tools built with nip44. Constructing it eagerly would throw for
82
+ // builds without nip44, so we defer to first use — mirrors ensureVoice.
83
+ let dm = null;
84
+ const ensureDM = () => {
85
+ if (!dm) dm = createDM({ relayPool: pool, auth, nostrTools });
86
+ return dm;
87
+ };
88
+
79
89
  const api = {
80
90
  pool, auth, fsm, message, bans, roles, settings, pages, media, channels, servers, chat,
81
91
  get voice() { return voice; },
82
92
  ensureVoice,
93
+ get dm() { return dm; },
94
+ ensureDM,
83
95
  setCurrentChannel,
84
96
  get currentChannelId() { return currentChannelId; },
85
97
  get currentServerId() { return servers.currentServerId; }