wireweave 0.1.1 → 0.1.3

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/src/roles.js ADDED
@@ -0,0 +1,58 @@
1
+ export class Roles extends EventTarget {
2
+ constructor({ relayPool, auth }) {
3
+ super();
4
+ if (!relayPool || !auth) throw new Error('Roles: deps required');
5
+ this.pool = relayPool; this.auth = auth;
6
+ this.store = new Map();
7
+ this.sub = null;
8
+ }
9
+
10
+ _creatorOf(serverId) { return serverId ? serverId.split(':')[0] : null; }
11
+
12
+ isOwner(serverId) { return !!this.auth.pubkey && this._creatorOf(serverId) === this.auth.pubkey; }
13
+ isAdmin(serverId) { if (this.isOwner(serverId)) return true; const r = this.store.get(serverId); return !!(r?.admins || []).includes(this.auth.pubkey); }
14
+ isMod(serverId) { if (this.isAdmin(serverId)) return true; const r = this.store.get(serverId); return !!(r?.mods || []).includes(this.auth.pubkey); }
15
+
16
+ getRole(serverId, pubkey) {
17
+ if (pubkey === this._creatorOf(serverId)) return 'owner';
18
+ const r = this.store.get(serverId) || {};
19
+ if ((r.admins || []).includes(pubkey)) return 'admin';
20
+ if ((r.mods || []).includes(pubkey)) return 'moderator';
21
+ return 'member';
22
+ }
23
+
24
+ async setRole(serverId, targetPubkey, role) {
25
+ if (!this.isOwner(serverId) && role === 'admin') throw new Error('Only owner can assign admin');
26
+ if (!this.isAdmin(serverId)) throw new Error('Insufficient permissions');
27
+ const existing = this.store.get(serverId) || { admins: [], mods: [] };
28
+ let admins = (existing.admins || []).filter(p => p !== targetPubkey);
29
+ let mods = (existing.mods || []).filter(p => p !== targetPubkey);
30
+ if (role === 'admin') admins = [...admins, targetPubkey];
31
+ else if (role === 'moderator') mods = [...mods, targetPubkey];
32
+ const next = { admins, mods };
33
+ this.store.set(serverId, next);
34
+ const signed = await this.auth.sign({ kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [['d', 'zellous-roles:' + serverId]], content: JSON.stringify(next) });
35
+ this.pool.publish(signed);
36
+ this.dispatchEvent(new CustomEvent('updated', { detail: { serverId, next } }));
37
+ }
38
+
39
+ subscribe(serverId) {
40
+ if (this.sub) { this.pool.unsubscribe(this.sub); this.sub = null; }
41
+ if (!serverId) return;
42
+ const creator = this._creatorOf(serverId);
43
+ if (!creator) return;
44
+ this.sub = 'roles-' + serverId;
45
+ this.pool.subscribe(this.sub,
46
+ [{ kinds: [30078], authors: [creator], '#d': ['zellous-roles:' + serverId] }],
47
+ (event) => {
48
+ if (event.pubkey !== creator) return;
49
+ try {
50
+ const data = JSON.parse(event.content);
51
+ this.store.set(serverId, { admins: data.admins || [], mods: data.mods || [] });
52
+ this.dispatchEvent(new CustomEvent('updated', { detail: { serverId, next: this.store.get(serverId) } }));
53
+ } catch {}
54
+ });
55
+ }
56
+ }
57
+
58
+ export const createRoles = (opts) => new Roles(opts);
package/src/servers.js ADDED
@@ -0,0 +1,103 @@
1
+ export class Servers extends EventTarget {
2
+ constructor({ relayPool, auth, storage, onSwitch = null }) {
3
+ super();
4
+ if (!relayPool || !auth || !storage) throw new Error('Servers: deps required');
5
+ this.pool = relayPool; this.auth = auth; this.storage = storage; this.onSwitch = onSwitch;
6
+ this.servers = []; this.currentServerId = null;
7
+ }
8
+
9
+ load() {
10
+ try { this.servers = JSON.parse(this.storage.getItem('zn_servers') || '[]'); } catch { this.servers = []; }
11
+ if (this.auth.pubkey) {
12
+ this.pool.subscribe('my-servers', [{ kinds: [34550], authors: [this.auth.pubkey] }], (ev) => this._handleEvent(ev));
13
+ }
14
+ try {
15
+ const joined = JSON.parse(this.storage.getItem('zn_joined_servers') || '[]');
16
+ joined.forEach(sid => { if (!this.servers.find(s => s.id === sid)) this.servers = [...this.servers, { id: sid, name: sid.slice(0, 8), iconColor: '#5865F2' }]; });
17
+ const unresolved = joined.filter(sid => { const p = sid.split(':'); return p.length === 2 && p[0] !== this.auth.pubkey; });
18
+ const byAuthor = {};
19
+ unresolved.forEach(sid => { const [author, dTag] = sid.split(':'); (byAuthor[author] = byAuthor[author] || []).push(dTag); });
20
+ Object.keys(byAuthor).forEach(author => {
21
+ this.pool.subscribe('joined-server-' + author.slice(0, 8),
22
+ [{ kinds: [34550], authors: [author], '#d': byAuthor[author] }],
23
+ (ev) => this._handleEvent(ev));
24
+ });
25
+ } catch {}
26
+ this._emit('updated', { servers: this.servers });
27
+ }
28
+
29
+ _handleEvent(event) {
30
+ const nameTag = event.tags.find(t => t[0] === 'name');
31
+ const colorTag = event.tags.find(t => t[0] === 'color');
32
+ const dTag = event.tags.find(t => t[0] === 'd');
33
+ if (!dTag) return;
34
+ const serverId = event.pubkey + ':' + dTag[1];
35
+ const name = nameTag ? nameTag[1] : serverId.slice(0, 8);
36
+ const iconColor = colorTag ? colorTag[1] : '#5865F2';
37
+ const existing = this.servers.find(s => s.id === serverId);
38
+ if (existing) { existing.name = name; existing.iconColor = iconColor; existing.ownerId = event.pubkey; this.servers = [...this.servers]; }
39
+ else this.servers = [...this.servers, { id: serverId, name, iconColor, ownerId: event.pubkey }];
40
+ this._persist();
41
+ this._emit('updated', { servers: this.servers });
42
+ }
43
+
44
+ async create(name, iconColor = '#5865F2') {
45
+ const dTag = Math.random().toString(36).slice(2, 10);
46
+ const serverId = this.auth.pubkey + ':' + dTag;
47
+ const signed = await this.auth.sign({ kind: 34550, created_at: Math.floor(Date.now() / 1000), tags: [['d', dTag], ['name', name], ['color', iconColor]], content: '' });
48
+ this.pool.publish(signed);
49
+ this.servers = [...this.servers, { id: serverId, name, iconColor, ownerId: this.auth.pubkey }];
50
+ this._persist();
51
+ await this.switchTo(serverId);
52
+ }
53
+
54
+ async join(serverId) {
55
+ try {
56
+ const joined = JSON.parse(this.storage.getItem('zn_joined_servers') || '[]');
57
+ if (!joined.includes(serverId)) { joined.push(serverId); this.storage.setItem('zn_joined_servers', JSON.stringify(joined)); }
58
+ } catch {}
59
+ if (!this.servers.find(s => s.id === serverId)) { this.servers = [...this.servers, { id: serverId, name: serverId.slice(0, 8), iconColor: '#5865F2' }]; this._persist(); }
60
+ await this.switchTo(serverId);
61
+ }
62
+
63
+ async delete(serverId) {
64
+ this.servers = this.servers.filter(s => s.id !== serverId);
65
+ this._persist();
66
+ try {
67
+ const joined = JSON.parse(this.storage.getItem('zn_joined_servers') || '[]');
68
+ this.storage.setItem('zn_joined_servers', JSON.stringify(joined.filter(id => id !== serverId)));
69
+ } catch {}
70
+ if (this.currentServerId === serverId) await this.switchTo(null);
71
+ else this._emit('updated', { servers: this.servers });
72
+ }
73
+
74
+ async leave(serverId) { return this.delete(serverId); }
75
+
76
+ async switchTo(serverId) {
77
+ this.currentServerId = serverId;
78
+ if (serverId) this.storage.setItem('zn_lastServer', serverId); else this.storage.removeItem('zn_lastServer');
79
+ this._emit('switched', { serverId });
80
+ await this.onSwitch?.(serverId);
81
+ }
82
+
83
+ init() {
84
+ this.load();
85
+ const last = this.storage.getItem('zn_lastServer');
86
+ if (last && this.servers.find(s => s.id === last)) this.switchTo(last);
87
+ else if (this.servers.length) this.switchTo(this.servers[0].id);
88
+ }
89
+
90
+ getOrder() { try { return JSON.parse(this.storage.getItem('zn_serverOrder') || '[]'); } catch { return []; } }
91
+ saveOrder(ids) { this.storage.setItem('zn_serverOrder', JSON.stringify(ids)); this._emit('updated', { servers: this.servers }); }
92
+ sorted() {
93
+ const order = this.getOrder();
94
+ if (!order.length) return this.servers;
95
+ const idx = {}; order.forEach((id, i) => idx[id] = i);
96
+ return this.servers.slice().sort((a, b) => (idx[a.id] ?? Infinity) - (idx[b.id] ?? Infinity));
97
+ }
98
+
99
+ _persist() { this.storage.setItem('zn_servers', JSON.stringify(this.servers)); }
100
+ _emit(t, d) { this.dispatchEvent(new CustomEvent(t, { detail: d })); }
101
+ }
102
+
103
+ export const createServers = (opts) => new Servers(opts);
@@ -0,0 +1,72 @@
1
+ const VALID_BITRATES = [8000, 16000, 24000, 48000, 96000];
2
+ const clampBitrate = (v) => VALID_BITRATES.reduce((prev, cur) => Math.abs(cur - v) < Math.abs(prev - v) ? cur : prev);
3
+
4
+ export class Settings extends EventTarget {
5
+ constructor({ relayPool, auth, roles }) {
6
+ super();
7
+ if (!relayPool || !auth || !roles) throw new Error('Settings: deps required');
8
+ this.pool = relayPool; this.auth = auth; this.roles = roles;
9
+ this.store = new Map();
10
+ this.sub = null;
11
+ }
12
+
13
+ getBitrate(serverId) { return this.store.get(serverId)?.opusBitrate || 24000; }
14
+
15
+ async setBitrate(serverId, bitrate) {
16
+ if (!this.roles.isOwner(serverId) && !this.roles.isAdmin(serverId)) throw new Error('Insufficient permissions');
17
+ const clamped = clampBitrate(Number(bitrate));
18
+ const existing = this.store.get(serverId) || {};
19
+ const next = { ...existing, opusBitrate: clamped };
20
+ this.store.set(serverId, next);
21
+ const signed = await this.auth.sign({ kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [['d', 'zellous-settings:' + serverId]], content: JSON.stringify(next) });
22
+ this.pool.publish(signed);
23
+ this.dispatchEvent(new CustomEvent('updated', { detail: { serverId, next } }));
24
+ return clamped;
25
+ }
26
+
27
+ getEmbedAllowlist(serverId) {
28
+ const s = this.store.get(serverId);
29
+ if (!s?.embedAllowlist) return [];
30
+ return typeof s.embedAllowlist === 'string' ? s.embedAllowlist.split(',').map(d => d.trim()).filter(Boolean) : s.embedAllowlist;
31
+ }
32
+
33
+ async setEmbedAllowlist(serverId, domainsStr) {
34
+ if (!this.roles.isOwner(serverId) && !this.roles.isAdmin(serverId)) throw new Error('Insufficient permissions');
35
+ const domains = domainsStr.split(',').map(d => d.trim()).filter(Boolean);
36
+ const existing = this.store.get(serverId) || {};
37
+ const next = { ...existing, embedAllowlist: domains };
38
+ this.store.set(serverId, next);
39
+ const signed = await this.auth.sign({ kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [['d', 'zellous-settings:' + serverId]], content: JSON.stringify(next) });
40
+ this.pool.publish(signed);
41
+ this.dispatchEvent(new CustomEvent('updated', { detail: { serverId, next } }));
42
+ return domains;
43
+ }
44
+
45
+ isOriginAllowed(serverId, origin) {
46
+ if (!origin) return false;
47
+ const allowlist = this.getEmbedAllowlist(serverId);
48
+ if (!allowlist.length) return true;
49
+ let url; try { url = new URL(origin); } catch { return false; }
50
+ return allowlist.some(p => {
51
+ if (p === '*') return true;
52
+ if (p.startsWith('*.')) { const s = p.slice(2); return url.hostname === s || url.hostname.endsWith('.' + s); }
53
+ return url.hostname === p || url.hostname === p.replace(/^https?:\/\//, '');
54
+ });
55
+ }
56
+
57
+ subscribe(serverId) {
58
+ if (this.sub) { this.pool.unsubscribe(this.sub); this.sub = null; }
59
+ if (!serverId) return;
60
+ const creator = serverId.split(':')[0];
61
+ if (!creator) return;
62
+ this.sub = 'settings-' + serverId;
63
+ this.pool.subscribe(this.sub,
64
+ [{ kinds: [30078], authors: [creator], '#d': ['zellous-settings:' + serverId] }],
65
+ (event) => {
66
+ if (event.pubkey !== creator) return;
67
+ try { this.store.set(serverId, JSON.parse(event.content)); this.dispatchEvent(new CustomEvent('updated', { detail: { serverId, next: this.store.get(serverId) } })); } catch {}
68
+ });
69
+ }
70
+ }
71
+
72
+ export const createSettings = (opts) => new Settings(opts);
package/src/voice.js ADDED
@@ -0,0 +1,348 @@
1
+ const ICE_SERVERS = [
2
+ { urls: 'stun:stun.l.google.com:19302' },
3
+ { urls: 'stun:stun1.l.google.com:19302' },
4
+ { urls: 'stun:stun.cloudflare.com:3478' },
5
+ { urls: 'stun:stun.nextcloud.com:443' },
6
+ { urls: 'turn:openrelay.metered.ca:80', username: 'openrelayproject', credential: 'openrelayproject' },
7
+ { urls: 'turn:openrelay.metered.ca:443', username: 'openrelayproject', credential: 'openrelayproject' },
8
+ { urls: 'turn:openrelay.metered.ca:443?transport=tcp', username: 'openrelayproject', credential: 'openrelayproject' },
9
+ { urls: 'turns:openrelay.metered.ca:443', username: 'openrelayproject', credential: 'openrelayproject' }
10
+ ];
11
+
12
+ const PRESENCE_EXPIRY = 300000;
13
+ const HEARTBEAT = 30000;
14
+ const STALL_CHECK = 5000;
15
+ const DISCONNECT_GRACE = 8000;
16
+
17
+ const deriveRoomId = async (serverId, channel) => {
18
+ const h = await crypto.subtle.digest('SHA-256', new TextEncoder().encode((serverId || 'default') + ':voice:' + channel));
19
+ return 'zellous' + Array.from(new Uint8Array(h)).map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 16);
20
+ };
21
+
22
+ export class VoiceSession extends EventTarget {
23
+ constructor({ fsm, xstate, relayPool, auth, mediaDevices, bans = null, serverId = '', onAudioTrack = null, onVideoTrack = null }) {
24
+ super();
25
+ if (!fsm || !xstate || !relayPool || !auth || !mediaDevices) throw new Error('VoiceSession: missing deps');
26
+ this.fsm = fsm; this.xstate = xstate; this.pool = relayPool; this.auth = auth; this.md = mediaDevices; this.bans = bans;
27
+ this.serverId = serverId;
28
+ this.onAudioTrack = onAudioTrack; this.onVideoTrack = onVideoTrack;
29
+ this.actor = null;
30
+ this.channelName = ''; this.roomId = '';
31
+ this.peers = new Map(); this.participants = new Map();
32
+ this.localStream = null; this.cameraStream = null;
33
+ this.heartbeat = null; this.joinTs = 0;
34
+ this.muted = false; this.deafened = false;
35
+ this.sfu = { mode: 'mesh', hub: null, hubLostAt: null, rttMatrix: new Map(), electionTimer: null, statsInterval: null, actor: null };
36
+ this.retrySchedule = {};
37
+ }
38
+
39
+ _initActor() {
40
+ this.actor = this.xstate.createActor(this.fsm.voiceMachine);
41
+ this.actor.subscribe((snap) => this.dispatchEvent(new CustomEvent('state', { detail: { value: snap.value } })));
42
+ this.actor.start();
43
+ }
44
+
45
+ async connect(channelName, { displayName = 'Guest' } = {}) {
46
+ if (!this.actor) this._initActor();
47
+ if (!this.actor.getSnapshot().can({ type: 'connect' })) await this.disconnect();
48
+ this.actor.send({ type: 'connect' });
49
+ this.channelName = channelName;
50
+ this.joinTs = Math.floor(Date.now() / 1000);
51
+ this.displayName = displayName;
52
+ try {
53
+ this.roomId = await deriveRoomId(this.serverId, channelName);
54
+ this.localStream = await this.md.getUserMedia({ audio: { echoCancellation: true, noiseSuppression: true, autoGainControl: true } });
55
+ this.participants.clear();
56
+ this.participants.set('local', { identity: displayName, isSpeaking: false, isMuted: false, isLocal: true, hasVideo: false, connectionQuality: 'good' });
57
+ this.actor.send({ type: 'connected' });
58
+ this._subscribeSignals();
59
+ this._subscribePresence();
60
+ await this._publishPresence('join');
61
+ this._startHeartbeat();
62
+ this._sfuStart();
63
+ this._emit('connected', { roomId: this.roomId, channelName });
64
+ } catch (e) {
65
+ this.actor.send({ type: 'fail' });
66
+ this._emit('error', { message: 'connect failed: ' + e.message });
67
+ throw e;
68
+ }
69
+ }
70
+
71
+ async disconnect() {
72
+ if (!this.actor || this.actor.getSnapshot().matches('idle')) return;
73
+ if (!this.actor.getSnapshot().can({ type: 'disconnect' })) return;
74
+ this.actor.send({ type: 'disconnect' });
75
+ await this._publishPresence('leave');
76
+ this._stopHeartbeat();
77
+ this._sfuStop();
78
+ for (const pk of Array.from(this.peers.keys())) this._closePeer(pk);
79
+ this.peers.clear();
80
+ if (this.cameraStream) { this.cameraStream.getTracks().forEach(t => t.stop()); this.cameraStream = null; }
81
+ if (this.localStream) { this.localStream.getTracks().forEach(t => t.stop()); this.localStream = null; }
82
+ if (this.roomId) { this.pool.unsubscribe('voice-presence-' + this.roomId); this.pool.unsubscribe('voice-signals-' + this.roomId); }
83
+ this.participants.clear();
84
+ this.roomId = ''; this.channelName = '';
85
+ this.muted = false; this.deafened = false;
86
+ this.actor.send({ type: 'done' });
87
+ this._emit('disconnected', {});
88
+ }
89
+
90
+ toggleMic() {
91
+ this.muted = !this.muted;
92
+ if (this.localStream) this.localStream.getAudioTracks().forEach(t => t.enabled = !this.muted);
93
+ const local = this.participants.get('local'); if (local) local.isMuted = this.muted;
94
+ this._emit('mic', { muted: this.muted });
95
+ this._emit('participants', { list: this.getParticipants() });
96
+ }
97
+
98
+ toggleDeafen() {
99
+ this.deafened = !this.deafened;
100
+ for (const [, peer] of this.peers) if (peer.audioEl) peer.audioEl.muted = this.deafened;
101
+ this._emit('deafen', { deafened: this.deafened });
102
+ }
103
+
104
+ getParticipants() { return Array.from(this.participants.values()); }
105
+
106
+ async _publishPresence(action) {
107
+ if (!this.auth.isLoggedIn() || !this.roomId) return;
108
+ const rttScores = action === 'heartbeat' ? this._rttScores() : {};
109
+ const signed = await this.auth.sign({
110
+ kind: 30078, created_at: Math.floor(Date.now() / 1000),
111
+ tags: [['d', 'zellous-voice:' + this.roomId], ['action', action], ['channel', this.channelName], ['server', this.serverId]],
112
+ content: JSON.stringify({ action, name: this.displayName, channel: this.channelName, ts: Date.now(), rttScores })
113
+ });
114
+ this.pool.publish(signed);
115
+ }
116
+
117
+ _startHeartbeat() { this._stopHeartbeat(); this.heartbeat = setInterval(() => { if (this.actor?.getSnapshot().matches('connected')) this._publishPresence('heartbeat'); }, HEARTBEAT); }
118
+ _stopHeartbeat() { if (this.heartbeat) { clearInterval(this.heartbeat); this.heartbeat = null; } }
119
+
120
+ _subscribePresence() {
121
+ this.pool.subscribe('voice-presence-' + this.roomId,
122
+ [{ kinds: [30078], '#d': ['zellous-voice:' + this.roomId] }],
123
+ (event) => this._onPresence(event));
124
+ }
125
+
126
+ _onPresence(event) {
127
+ if (event.pubkey === this.auth.pubkey) return;
128
+ let data; try { data = JSON.parse(event.content); } catch { return; }
129
+ if (Date.now() - (data.ts || 0) > PRESENCE_EXPIRY) return;
130
+ const shortId = 'nostr-' + event.pubkey.slice(0, 12);
131
+ if (data.rttScores) this.sfu.rttMatrix.set(event.pubkey, data.rttScores);
132
+ if (data.action === 'leave') { this.participants.delete(shortId); this._closePeer(event.pubkey); }
133
+ else if (!this.participants.has(shortId)) {
134
+ this.participants.set(shortId, { identity: data.name || event.pubkey.slice(0, 8), isSpeaking: false, isMuted: false, isLocal: false, hasVideo: false, connectionQuality: 'connecting' });
135
+ this._maybeConnect(event.pubkey);
136
+ } else if (!this.peers.has(event.pubkey)) this._maybeConnect(event.pubkey);
137
+ this._emit('participants', { list: this.getParticipants() });
138
+ }
139
+
140
+ _subscribeSignals() {
141
+ this.pool.subscribe('voice-signals-' + this.roomId,
142
+ [{ kinds: [30078], '#p': [this.auth.pubkey], '#r': [this.roomId] }],
143
+ (event) => this._handleSignal(event));
144
+ }
145
+
146
+ _maybeConnect(peerPubkey) {
147
+ if (!peerPubkey || peerPubkey === this.auth.pubkey || this.peers.has(peerPubkey)) return;
148
+ if (this.bans && this.serverId && (this.bans.isBanned?.(this.serverId, peerPubkey) || this.bans.isTimedOut?.(this.serverId, peerPubkey))) return;
149
+ this._cancelReconnect(peerPubkey);
150
+ const fsmActor = this.xstate.createActor(this.fsm.peerMachine);
151
+ fsmActor.subscribe((snap) => { const p = this.peers.get(peerPubkey); if (p) p.state = snap.value; });
152
+ fsmActor.start();
153
+ const peer = { pc: null, audioEl: null, pendingCandidates: [], bufferedCandidates: [], iceTimer: null, disconnectTimer: null, failCount: 0, state: 'new', fsm: fsmActor, _stallInterval: null, remoteDescSet: false, trackEndedRestart: false };
154
+ this.peers.set(peerPubkey, peer);
155
+ const pc = new RTCPeerConnection({ iceServers: ICE_SERVERS, bundlePolicy: 'max-bundle' });
156
+ peer.pc = pc;
157
+ const isOfferer = this.auth.pubkey > peerPubkey;
158
+ if (isOfferer) {
159
+ if (this.localStream) this.localStream.getTracks().forEach(t => pc.addTransceiver(t, { direction: 'sendrecv', streams: [this.localStream] }));
160
+ else pc.addTransceiver('audio', { direction: 'recvonly' });
161
+ }
162
+ this._wirePeer(peer, peerPubkey, fsmActor, isOfferer);
163
+ }
164
+
165
+ _wirePeer(peer, peerPubkey, fsmActor, isOfferer) {
166
+ const pc = peer.pc;
167
+ pc.ontrack = (ev) => {
168
+ if (ev.track.kind === 'video') { this.onVideoTrack?.({ peerPubkey, track: ev.track, stream: ev.streams[0] }); return; }
169
+ this.onAudioTrack?.({ peerPubkey, track: ev.track, stream: ev.streams[0], peer });
170
+ try { ev.receiver.playoutDelayHint = 0.02; } catch {}
171
+ ev.track.onended = () => { if (peer.fsm.getSnapshot().matches('connected')) this._doIceRestart(peer, peerPubkey, fsmActor); };
172
+ if (!peer._stallInterval) peer._stallInterval = setInterval(() => this._checkStall(peer, peerPubkey, fsmActor), STALL_CHECK);
173
+ };
174
+ pc.onicecandidate = (ev) => {
175
+ if (!ev.candidate) return;
176
+ peer.pendingCandidates.push(ev.candidate.toJSON());
177
+ if (peer.iceTimer) clearTimeout(peer.iceTimer);
178
+ peer.iceTimer = setTimeout(() => { if (peer.pendingCandidates.length) { this._publishSignal(peerPubkey, 'ice', peer.pendingCandidates.splice(0)); peer.iceTimer = null; } }, 500);
179
+ };
180
+ pc.onicegatheringstatechange = () => { if (pc.iceGatheringState === 'complete') { if (peer.iceTimer) { clearTimeout(peer.iceTimer); peer.iceTimer = null; } if (peer.pendingCandidates.length) this._publishSignal(peerPubkey, 'ice', peer.pendingCandidates.splice(0)); } };
181
+ pc.onconnectionstatechange = () => {
182
+ if (pc.connectionState === 'connected') {
183
+ peer.failCount = 0; this._cancelReconnect(peerPubkey);
184
+ if (fsmActor.getSnapshot().can({ type: 'recv_answer' })) fsmActor.send({ type: 'recv_answer' });
185
+ if (peer.disconnectTimer) { clearTimeout(peer.disconnectTimer); peer.disconnectTimer = null; }
186
+ this._applyAudioHints(pc);
187
+ }
188
+ if (pc.connectionState === 'disconnected') { fsmActor.send({ type: 'disconnect' }); peer.disconnectTimer = setTimeout(() => this._doIceRestart(peer, peerPubkey, fsmActor), DISCONNECT_GRACE); }
189
+ if (pc.connectionState === 'failed') this._doIceRestart(peer, peerPubkey, fsmActor);
190
+ if (pc.connectionState === 'closed') { this._closePeer(peerPubkey); if (this.sfu.hub === peerPubkey) { this._sfuDissolve(); setTimeout(() => this._sfuMaybeElect(), 500); } }
191
+ };
192
+ if (isOfferer) {
193
+ fsmActor.send({ type: 'offer' });
194
+ pc.createOffer().then(o => pc.setLocalDescription(o).then(() => this._publishSignal(peerPubkey, 'offer', o))).catch(() => {});
195
+ }
196
+ }
197
+
198
+ _applyAudioHints(pc) {
199
+ try {
200
+ pc.getSenders().forEach(s => { if (!s.track || s.track.kind !== 'audio') return; const p = s.getParameters(); if (!p.encodings?.length) return; p.encodings[0].networkPriority = 'high'; p.encodings[0].maxBitrate = 48000; s.setParameters(p).catch(() => {}); });
201
+ pc.getReceivers().forEach(r => { if (r.track?.kind !== 'audio') return; try { r.playoutDelayHint = 0.02; } catch {} });
202
+ } catch {}
203
+ }
204
+
205
+ _checkStall(peer, peerPubkey, fsmActor) {
206
+ if (!peer.audioEl?.srcObject || !peer.fsm.getSnapshot().matches('connected') || peer.trackEndedRestart) return;
207
+ const allEnded = peer.audioEl.srcObject.getTracks().every(t => t.readyState === 'ended');
208
+ if (!allEnded) return;
209
+ peer.trackEndedRestart = true;
210
+ this._doIceRestart(peer, peerPubkey, fsmActor);
211
+ }
212
+
213
+ _doIceRestart(peer, peerPubkey, fsmActor) {
214
+ const pc = peer.pc;
215
+ if (peer.disconnectTimer) { clearTimeout(peer.disconnectTimer); peer.disconnectTimer = null; }
216
+ peer.failCount++;
217
+ if (peer.failCount <= 1 && this.auth.pubkey > peerPubkey) {
218
+ fsmActor.send({ type: 'restart' }); pc.restartIce();
219
+ pc.createOffer({ iceRestart: true }).then(o => pc.setLocalDescription(o).then(() => this._publishSignal(peerPubkey, 'offer', o))).catch(() => this._closePeer(peerPubkey));
220
+ } else { this._closePeer(peerPubkey); this._scheduleReconnect(peerPubkey, peer.failCount); }
221
+ }
222
+
223
+ _handleSignal(event) {
224
+ const from = event.pubkey; if (from === this.auth.pubkey) return;
225
+ let data; try { data = JSON.parse(event.content); } catch { return; }
226
+ if (!data?.type) return;
227
+ if (!this.peers.has(from)) this._maybeConnect(from);
228
+ const peer = this.peers.get(from); if (!peer) return;
229
+ const pc = peer.pc; const fsmActor = peer.fsm;
230
+ const addCands = (cands) => cands.forEach(c => pc.addIceCandidate(new RTCIceCandidate(c)).catch(() => {}));
231
+ const drainBuf = () => { addCands(peer.bufferedCandidates); peer.bufferedCandidates = []; };
232
+ const doAnswer = async () => {
233
+ if (fsmActor.getSnapshot().can({ type: 'recv_offer' })) fsmActor.send({ type: 'recv_offer' });
234
+ await pc.setRemoteDescription(new RTCSessionDescription(data.data));
235
+ peer.remoteDescSet = true; drainBuf();
236
+ const hasAudioTx = pc.getTransceivers().some(t => t.receiver.track?.kind === 'audio');
237
+ if (!hasAudioTx) pc.addTransceiver('audio', { direction: this.localStream ? 'sendrecv' : 'recvonly' });
238
+ if (this.localStream) { const hasSender = pc.getSenders().some(s => s.track?.kind === 'audio'); if (!hasSender) this.localStream.getTracks().forEach(t => pc.addTrack(t, this.localStream)); }
239
+ const a = await pc.createAnswer(); await pc.setLocalDescription(a); fsmActor.send({ type: 'sent_answer' }); this._publishSignal(from, 'answer', a);
240
+ };
241
+ if (data.type === 'offer') {
242
+ const polite = this.auth.pubkey < from; const collision = pc.signalingState !== 'stable';
243
+ if (collision && !polite) return;
244
+ if (collision && polite) { pc.setLocalDescription({ type: 'rollback' }).then(doAnswer).catch(() => {}); return; }
245
+ doAnswer().catch(() => {});
246
+ } else if (data.type === 'answer' && pc.signalingState === 'have-local-offer') {
247
+ pc.setRemoteDescription(new RTCSessionDescription(data.data)).then(() => { peer.remoteDescSet = true; drainBuf(); }).catch(() => {});
248
+ } else if (data.type === 'ice') {
249
+ const cands = Array.isArray(data.data) ? data.data : [data.data];
250
+ if (peer.remoteDescSet) addCands(cands); else peer.bufferedCandidates.push(...cands);
251
+ }
252
+ }
253
+
254
+ async _publishSignal(toPubkey, type, data) {
255
+ if (!this.auth.pubkey || !this.roomId) return;
256
+ const d = 'zellous-rtc:' + this.roomId + ':' + this.auth.pubkey + ':' + toPubkey + ':' + type + ':' + (type === 'ice' ? Date.now() : 'sdp');
257
+ const signed = await this.auth.sign({ kind: 30078, created_at: Math.floor(Date.now() / 1000), tags: [['d', d], ['p', toPubkey], ['r', this.roomId]], content: JSON.stringify({ type, data }) });
258
+ this.pool.publish(signed);
259
+ }
260
+
261
+ _closePeer(peerPubkey) {
262
+ const peer = this.peers.get(peerPubkey); if (!peer) return;
263
+ if (peer.iceTimer) clearTimeout(peer.iceTimer);
264
+ if (peer.disconnectTimer) clearTimeout(peer.disconnectTimer);
265
+ if (peer._stallInterval) clearInterval(peer._stallInterval);
266
+ try { peer.pc?.close(); } catch {}
267
+ if (peer.audioEl) { peer.audioEl.srcObject = null; peer.audioEl.remove(); }
268
+ this.peers.delete(peerPubkey);
269
+ this._emit('peer-closed', { peerPubkey });
270
+ }
271
+
272
+ _cancelReconnect(pk) { const e = this.retrySchedule[pk]; if (e) { clearTimeout(e.timer); delete this.retrySchedule[pk]; } }
273
+ _scheduleReconnect(pk, attempt) {
274
+ const a = attempt || 0; if (a >= 6) return;
275
+ this._cancelReconnect(pk);
276
+ const timer = setTimeout(() => { delete this.retrySchedule[pk]; if (!this.peers.has(pk) && this.roomId) this._maybeConnect(pk); }, Math.min(2 ** a * 2000, 30000));
277
+ this.retrySchedule[pk] = { attempt: a, timer };
278
+ }
279
+
280
+ _sfuStart() { this.sfu.actor = this.xstate.createActor(this.fsm.sfuMachine); this.sfu.actor.start(); this._sfuStopStats(); this.sfu.statsInterval = setInterval(() => this._sfuPoll(), 5000); }
281
+ _sfuStop() { this._sfuStopStats(); this.sfu.actor?.send({ type: 'dissolve' }); this.sfu.hub = null; this.sfu.rttMatrix.clear(); }
282
+ _sfuStopStats() { if (this.sfu.statsInterval) { clearInterval(this.sfu.statsInterval); this.sfu.statsInterval = null; } if (this.sfu.electionTimer) { clearTimeout(this.sfu.electionTimer); this.sfu.electionTimer = null; } }
283
+ async _sfuPoll() {
284
+ const scores = {}; const tasks = [];
285
+ for (const [pk, peer] of this.peers) {
286
+ if (!peer.pc || peer.pc.connectionState !== 'connected') continue;
287
+ tasks.push(peer.pc.getStats().then(stats => stats.forEach(r => { if (r.type === 'candidate-pair' && r.state === 'succeeded' && r.currentRoundTripTime != null) scores[pk] = Math.round(r.currentRoundTripTime * 1000); })).catch(() => {}));
288
+ }
289
+ await Promise.all(tasks);
290
+ this.sfu.rttMatrix.set(this.auth.pubkey, scores);
291
+ this._sfuMaybeElect();
292
+ }
293
+ _sfuMaybeElect() {
294
+ if (this.peers.size < 3) { if (this.sfu.actor?.getSnapshot().value !== 'mesh') this._sfuDissolve(); return; }
295
+ if (this.sfu.electionTimer) return;
296
+ this.sfu.electionTimer = setTimeout(() => { this.sfu.electionTimer = null; this._sfuElect(); }, 2000);
297
+ }
298
+ _sfuElect() {
299
+ const all = [this.auth.pubkey]; for (const pk of this.peers.keys()) all.push(pk);
300
+ let best = null, bestAvg = Infinity;
301
+ for (const pk of all) {
302
+ const s = this.sfu.rttMatrix.get(pk); if (!s) continue;
303
+ const vals = Object.values(s).filter(v => typeof v === 'number'); if (!vals.length) continue;
304
+ const avg = vals.reduce((a, b) => a + b, 0) / vals.length;
305
+ if (avg < bestAvg || (avg === bestAvg && pk > best)) { bestAvg = avg; best = pk; }
306
+ }
307
+ if (!best || best === this.sfu.hub) return;
308
+ this.sfu.hub = best; this.sfu.actor?.send({ type: 'elected' });
309
+ if (best === this.auth.pubkey) this._sfuBecomeHub(); else this._sfuRouteToHub(best);
310
+ }
311
+ _sfuBecomeHub() {
312
+ for (const [srcPk, srcPeer] of this.peers) {
313
+ if (!srcPeer.pc?.getReceivers) continue;
314
+ srcPeer.pc.getReceivers().forEach(recv => {
315
+ if (recv.track?.kind !== 'audio') return;
316
+ for (const [dstPk, dstPeer] of this.peers) {
317
+ if (dstPk === srcPk) continue;
318
+ const sender = dstPeer.pc?.getSenders().find(s => s.track?.kind === 'audio');
319
+ sender?.replaceTrack(recv.track).catch(() => {});
320
+ }
321
+ });
322
+ }
323
+ }
324
+ _sfuRouteToHub(hubPk) {
325
+ for (const pk of Array.from(this.peers.keys())) { if (pk === hubPk) continue; this._closePeer(pk); }
326
+ if (!this.peers.has(hubPk)) this._maybeConnect(hubPk);
327
+ }
328
+ _sfuDissolve() { this.sfu.actor?.send({ type: 'dissolve' }); this.sfu.hubLostAt = Date.now(); this.sfu.hub = null; }
329
+
330
+ _rttScores() { return this.sfu.rttMatrix.get(this.auth.pubkey) || {}; }
331
+
332
+ debug() {
333
+ const peers = [];
334
+ for (const [pk, peer] of this.peers) peers.push({ pubkey: pk.slice(0, 12), fsmState: peer.fsm?.getSnapshot().value, iceState: peer.pc?.iceConnectionState, connState: peer.pc?.connectionState, candidates: peer.pendingCandidates.length, buffered: peer.bufferedCandidates.length });
335
+ const rttMatrix = {}; for (const [k, v] of this.sfu.rttMatrix) rttMatrix[k.slice(0, 12)] = v;
336
+ return { fsm: this.actor?.getSnapshot().value, peers, participants: this.getParticipants(), sfu: { mode: this.sfu.actor?.getSnapshot().value || 'mesh', hub: this.sfu.hub?.slice(0, 12) || null, rttMatrix }, retrySchedule: this.retrySchedule };
337
+ }
338
+
339
+ heal() {
340
+ if (!this.roomId) return;
341
+ const bad = { disconnected: 1, failed: 1, closed: 1 };
342
+ for (const [pk, peer] of this.peers) if (bad[peer.pc?.connectionState]) { this._closePeer(pk); this._scheduleReconnect(pk, 0); }
343
+ }
344
+
345
+ _emit(t, d) { this.dispatchEvent(new CustomEvent(t, { detail: d })); }
346
+ }
347
+
348
+ export const createVoiceSession = (opts) => new VoiceSession(opts);
@@ -0,0 +1,89 @@
1
+ import { createRelayPool } from './relay-pool.js';
2
+ import { createAuth } from './auth.js';
3
+ import { createFSM } from './fsm.js';
4
+ import { createVoiceSession } from './voice.js';
5
+ import { createChat } from './chat.js';
6
+ import { createChannels } from './channels.js';
7
+ import { createServers } from './servers.js';
8
+ import { createMessageBus } from './message.js';
9
+ import { createBans } from './bans.js';
10
+ import { createRoles } from './roles.js';
11
+ import { createSettings } from './settings.js';
12
+ import { createMedia } from './media.js';
13
+ import { createPages } from './pages.js';
14
+ import { register } from './debug.js';
15
+
16
+ export const createWireweave = ({
17
+ nostrTools,
18
+ xstate,
19
+ storage = (typeof localStorage !== 'undefined' ? localStorage : null),
20
+ extension = (typeof window !== 'undefined' ? window.nostr : null),
21
+ relays = ['wss://relay.damus.io', 'wss://relay.primal.net', 'wss://nos.lol', 'wss://relay.snort.social'],
22
+ mediaDevices = (typeof navigator !== 'undefined' ? navigator.mediaDevices : null),
23
+ WebSocketImpl = (typeof WebSocket !== 'undefined' ? WebSocket : null)
24
+ } = {}) => {
25
+ if (!nostrTools) throw new Error('wireweave: nostrTools required');
26
+ if (!xstate) throw new Error('wireweave: xstate required');
27
+
28
+ const fsm = createFSM(xstate);
29
+ const pool = createRelayPool({ relays, verifyEvent: nostrTools.verifyEvent, WebSocketImpl });
30
+ const auth = createAuth({ nostrTools, storage, extension });
31
+ const message = createMessageBus();
32
+ const bans = createBans({ relayPool: pool });
33
+ const roles = createRoles({ relayPool: pool, auth });
34
+ const settings = createSettings({ relayPool: pool, auth, roles });
35
+ const pages = createPages({ relayPool: pool, auth, roles });
36
+ const media = createMedia({ relayPool: pool, auth });
37
+ const channels = createChannels({ relayPool: pool, auth });
38
+
39
+ let currentChannelId = null;
40
+ const chat = createChat({
41
+ relayPool: pool, auth,
42
+ getChannelContext: () => ({ channelId: currentChannelId, serverId: servers.currentServerId || '' }),
43
+ isAdmin: (sid) => roles.isAdmin(sid)
44
+ });
45
+
46
+ const servers = createServers({
47
+ relayPool: pool, auth, storage,
48
+ onSwitch: async (serverId) => {
49
+ roles.subscribe(serverId);
50
+ settings.subscribe(serverId);
51
+ bans.subscribe(serverId);
52
+ pages.subscribe(serverId);
53
+ if (serverId) {
54
+ await new Promise((resolve) => {
55
+ let done = false;
56
+ const finish = () => { if (done) return; done = true; resolve(); };
57
+ channels.load(serverId, finish);
58
+ setTimeout(finish, 3000);
59
+ });
60
+ }
61
+ const firstText = channels.channels.find(c => c.type === 'text');
62
+ if (firstText) {
63
+ currentChannelId = firstText.id;
64
+ chat.loadHistory(firstText.id);
65
+ }
66
+ }
67
+ });
68
+
69
+ let voice = null;
70
+ const ensureVoice = ({ serverId, displayName, onAudioTrack, onVideoTrack }) => {
71
+ if (!voice) voice = createVoiceSession({ fsm, xstate, relayPool: pool, auth, mediaDevices, bans, serverId, onAudioTrack, onVideoTrack });
72
+ else voice.serverId = serverId;
73
+ return voice;
74
+ };
75
+
76
+ const setCurrentChannel = (id) => { currentChannelId = id; if (id) chat.loadHistory(id); };
77
+
78
+ const api = {
79
+ pool, auth, fsm, message, bans, roles, settings, pages, media, channels, servers, chat,
80
+ get voice() { return voice; },
81
+ ensureVoice,
82
+ setCurrentChannel,
83
+ get currentChannelId() { return currentChannelId; },
84
+ get currentServerId() { return servers.currentServerId; }
85
+ };
86
+
87
+ register('wireweave', api);
88
+ return api;
89
+ };