wireweave 0.2.3 → 0.3.1
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 +45 -3
- package/package.json +3 -2
- package/src/data.js +303 -0
- package/src/fsm.js +10 -0
- package/src/index.js +1 -0
- package/src/relay-pool.js +7 -1
- package/src/wireweave.js +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
> probably emerging 🌀
|
|
4
4
|
|
|
5
|
-
serverless nostr + webrtc voice SDK. the networking layer for 247420 projects.
|
|
5
|
+
serverless nostr + webrtc voice + binary-data SDK. the networking layer for 247420 projects.
|
|
6
|
+
|
|
7
|
+
- **voice** — perfect-negotiation mesh + SFU election (audio, video, recorded segments).
|
|
8
|
+
- **data** — peer-to-peer binary `RTCDataChannel` over the same nostr signaling. game frames, structured payloads, anything `Uint8Array`-shaped.
|
|
6
9
|
|
|
7
10
|
site: https://anentrypoint.github.io/wireweave/
|
|
8
11
|
npm: https://www.npmjs.com/package/wireweave
|
|
@@ -32,12 +35,51 @@ every submodule is an `EventTarget`. subscribe with `addEventListener('event', .
|
|
|
32
35
|
|
|
33
36
|
```js
|
|
34
37
|
import {
|
|
35
|
-
RelayPool, NostrAuth, VoiceSession, Chat, Channels, Servers,
|
|
38
|
+
RelayPool, NostrAuth, VoiceSession, DataSession, Chat, Channels, Servers,
|
|
36
39
|
Bans, Roles, Settings, Media, Pages, MessageBus, createFSM
|
|
37
40
|
} from 'wireweave';
|
|
38
41
|
```
|
|
39
42
|
|
|
40
|
-
each also exported via subpath: `wireweave/relay-pool`, `wireweave/voice`, `wireweave/chat`, etc.
|
|
43
|
+
each also exported via subpath: `wireweave/relay-pool`, `wireweave/voice`, `wireweave/data`, `wireweave/chat`, etc.
|
|
44
|
+
|
|
45
|
+
## data sessions (binary p2p)
|
|
46
|
+
|
|
47
|
+
`DataSession` mirrors `VoiceSession`'s perfect-negotiation peer setup but carries **only** an ordered binary `RTCDataChannel` — no media, no mic prompt, no SFU. Use it for game state, file sync, anything `ArrayBuffer`-friendly.
|
|
48
|
+
|
|
49
|
+
```js
|
|
50
|
+
import { createDataSession, createFSM, NostrAuth, RelayPool } from 'wireweave';
|
|
51
|
+
import * as NostrTools from 'nostr-tools';
|
|
52
|
+
import * as xstate from 'xstate';
|
|
53
|
+
|
|
54
|
+
const fsm = createFSM(xstate);
|
|
55
|
+
const auth = new NostrAuth({ nostrTools: NostrTools });
|
|
56
|
+
auth.loadFromStorage() || auth.generateKey();
|
|
57
|
+
const pool = new RelayPool({ verifyEvent: NostrTools.verifyEvent });
|
|
58
|
+
pool.connect();
|
|
59
|
+
|
|
60
|
+
const session = createDataSession({ fsm, xstate, relayPool: pool, auth, namespace: 'mygame' });
|
|
61
|
+
session.addEventListener('peer-open', (e) => console.log('peer up', e.detail.peerPubkey));
|
|
62
|
+
session.addEventListener('data', (e) => handleFrame(e.detail.peerPubkey, e.detail.data));
|
|
63
|
+
session.addEventListener('peer-close', (e) => console.log('peer down', e.detail.peerPubkey));
|
|
64
|
+
|
|
65
|
+
await session.connect('lobby-7'); // any room name; URL-hash works fine
|
|
66
|
+
session.broadcast(new Uint8Array(payload)); // → all open peers
|
|
67
|
+
session.send(somePeerPubkey, new Uint8Array(p)); // → one peer
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Options: `dataChannelOptions` (default `{ ordered: true }`) and `iceServers` (override the default STUN/TURN list). Construct multiple `DataSession`s in the same page to use distinct rooms / channel configurations.
|
|
71
|
+
|
|
72
|
+
## game / 3-mode usage pattern
|
|
73
|
+
|
|
74
|
+
For projects (e.g. multiplayer games) that need three transport flavours:
|
|
75
|
+
|
|
76
|
+
| mode | wireweave usage |
|
|
77
|
+
|---|---|
|
|
78
|
+
| singleplayer (in-page) | `VoiceSession` keyed by `location.hash` so people on the same URL voice-chat; game state stays in a Worker |
|
|
79
|
+
| webrtc host & join | `DataSession` for game frames, `VoiceSession` for voice — both signaled through the same nostr relays |
|
|
80
|
+
| self-hosted server | server runs its own transport for state; `DataSession` adds player↔player p2p (voice + side-channel data) over nostr |
|
|
81
|
+
|
|
82
|
+
`namespace` partitions rooms across deployments (e.g. `'spoint-prod'` vs `'spoint-dev'`).
|
|
41
83
|
|
|
42
84
|
## voice (webrtc mesh + sfu hub election)
|
|
43
85
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wireweave",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "nostr + webrtc voice SDK. networking layer for 247420 projects.",
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "nostr + webrtc voice + data SDK. networking layer for 247420 projects.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.js",
|
|
7
7
|
"module": "./src/index.js",
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
"./auth": "./src/auth.js",
|
|
13
13
|
"./fsm": "./src/fsm.js",
|
|
14
14
|
"./voice": "./src/voice.js",
|
|
15
|
+
"./data": "./src/data.js",
|
|
15
16
|
"./chat": "./src/chat.js",
|
|
16
17
|
"./channels": "./src/channels.js",
|
|
17
18
|
"./servers": "./src/servers.js",
|
package/src/data.js
ADDED
|
@@ -0,0 +1,303 @@
|
|
|
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: 'turn:openrelay.metered.ca:80', username: 'openrelayproject', credential: 'openrelayproject' },
|
|
6
|
+
{ urls: 'turn:openrelay.metered.ca:443', username: 'openrelayproject', credential: 'openrelayproject' },
|
|
7
|
+
{ urls: 'turn:openrelay.metered.ca:443?transport=tcp', username: 'openrelayproject', credential: 'openrelayproject' }
|
|
8
|
+
];
|
|
9
|
+
|
|
10
|
+
const PRESENCE_EXPIRY = 300000;
|
|
11
|
+
const HEARTBEAT = 30000;
|
|
12
|
+
const DISCONNECT_GRACE = 8000;
|
|
13
|
+
const DC_LABEL = 'wireweave-data';
|
|
14
|
+
|
|
15
|
+
const deriveRoomId = async (namespace, room) => {
|
|
16
|
+
const h = await crypto.subtle.digest('SHA-256', new TextEncoder().encode((namespace || 'default') + ':data:' + room));
|
|
17
|
+
return 'wwdata' + Array.from(new Uint8Array(h)).map(b => b.toString(16).padStart(2, '0')).join('').slice(0, 16);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export class DataSession extends EventTarget {
|
|
21
|
+
constructor({ fsm, xstate, relayPool, auth, namespace = '', dataChannelOptions = { ordered: true }, iceServers = null }) {
|
|
22
|
+
super();
|
|
23
|
+
if (!fsm || !xstate || !relayPool || !auth) throw new Error('DataSession: missing deps');
|
|
24
|
+
this.fsm = fsm; this.xstate = xstate; this.pool = relayPool; this.auth = auth;
|
|
25
|
+
this.namespace = namespace;
|
|
26
|
+
this.dcOptions = dataChannelOptions;
|
|
27
|
+
this.iceServers = iceServers || ICE_SERVERS;
|
|
28
|
+
this.actor = null;
|
|
29
|
+
this.room = ''; this.roomId = '';
|
|
30
|
+
this.peers = new Map(); this.participants = new Map();
|
|
31
|
+
this.heartbeat = null; this.joinTs = 0;
|
|
32
|
+
this.retrySchedule = {};
|
|
33
|
+
this.displayName = '';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
_initActor() {
|
|
37
|
+
const machine = this.fsm.dataMachine || this.fsm.voiceMachine;
|
|
38
|
+
this.actor = this.xstate.createActor(machine);
|
|
39
|
+
this.actor.subscribe((snap) => this.dispatchEvent(new CustomEvent('state', { detail: { value: snap.value } })));
|
|
40
|
+
this.actor.start();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async connect(room, { displayName = 'Guest' } = {}) {
|
|
44
|
+
if (!this.actor) this._initActor();
|
|
45
|
+
if (!this.actor.getSnapshot().can({ type: 'connect' })) await this.disconnect();
|
|
46
|
+
this.actor.send({ type: 'connect' });
|
|
47
|
+
this.room = room;
|
|
48
|
+
this.displayName = displayName;
|
|
49
|
+
this.joinTs = Math.floor(Date.now() / 1000);
|
|
50
|
+
try {
|
|
51
|
+
this.roomId = await deriveRoomId(this.namespace, room);
|
|
52
|
+
this.participants.clear();
|
|
53
|
+
this.participants.set('local', { identity: displayName, isLocal: true, connectionQuality: 'good' });
|
|
54
|
+
this.actor.send({ type: 'connected' });
|
|
55
|
+
this._subscribeSignals();
|
|
56
|
+
this._subscribePresence();
|
|
57
|
+
await this._publishPresence('join');
|
|
58
|
+
this._startHeartbeat();
|
|
59
|
+
this._emit('connected', { roomId: this.roomId, room });
|
|
60
|
+
} catch (e) {
|
|
61
|
+
this.actor.send({ type: 'fail' });
|
|
62
|
+
this._emit('error', { message: 'connect failed: ' + e.message });
|
|
63
|
+
throw e;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async disconnect() {
|
|
68
|
+
if (!this.actor || this.actor.getSnapshot().matches('idle')) return;
|
|
69
|
+
if (!this.actor.getSnapshot().can({ type: 'disconnect' })) return;
|
|
70
|
+
this.actor.send({ type: 'disconnect' });
|
|
71
|
+
await this._publishPresence('leave');
|
|
72
|
+
this._stopHeartbeat();
|
|
73
|
+
for (const pk of Array.from(this.peers.keys())) this._closePeer(pk);
|
|
74
|
+
this.peers.clear();
|
|
75
|
+
if (this.roomId) {
|
|
76
|
+
this.pool.unsubscribe('data-presence-' + this.roomId);
|
|
77
|
+
this.pool.unsubscribe('data-signals-' + this.roomId);
|
|
78
|
+
}
|
|
79
|
+
this.participants.clear();
|
|
80
|
+
this.roomId = ''; this.room = '';
|
|
81
|
+
this.actor.send({ type: 'done' });
|
|
82
|
+
this._emit('disconnected', {});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
send(peerPubkey, payload) {
|
|
86
|
+
const peer = this.peers.get(peerPubkey);
|
|
87
|
+
if (!peer?.dc || peer.dc.readyState !== 'open') return false;
|
|
88
|
+
try { peer.dc.send(payload); return true; } catch { return false; }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
broadcast(payload) {
|
|
92
|
+
let n = 0;
|
|
93
|
+
for (const [, peer] of this.peers) {
|
|
94
|
+
if (peer.dc?.readyState === 'open') {
|
|
95
|
+
try { peer.dc.send(payload); n++; } catch {}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return n;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
getParticipants() { return Array.from(this.participants.values()); }
|
|
102
|
+
getPeers() { return Array.from(this.peers.keys()); }
|
|
103
|
+
|
|
104
|
+
async _publishPresence(action) {
|
|
105
|
+
if (!this.auth.isLoggedIn() || !this.roomId) return;
|
|
106
|
+
const signed = await this.auth.sign({
|
|
107
|
+
kind: 30078, created_at: Math.floor(Date.now() / 1000),
|
|
108
|
+
tags: [['d', 'wireweave-data:' + this.roomId], ['action', action], ['room', this.room], ['ns', this.namespace]],
|
|
109
|
+
content: JSON.stringify({ action, name: this.displayName, room: this.room, ts: Date.now() })
|
|
110
|
+
});
|
|
111
|
+
this.pool.publish(signed);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
_startHeartbeat() {
|
|
115
|
+
this._stopHeartbeat();
|
|
116
|
+
this.heartbeat = setInterval(() => { if (this.actor?.getSnapshot().matches('connected')) this._publishPresence('heartbeat'); }, HEARTBEAT);
|
|
117
|
+
}
|
|
118
|
+
_stopHeartbeat() { if (this.heartbeat) { clearInterval(this.heartbeat); this.heartbeat = null; } }
|
|
119
|
+
|
|
120
|
+
_subscribePresence() {
|
|
121
|
+
this.pool.subscribe('data-presence-' + this.roomId,
|
|
122
|
+
[{ kinds: [30078], '#d': ['wireweave-data:' + this.roomId] }],
|
|
123
|
+
(event) => this._onPresence(event));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
_subscribeSignals() {
|
|
127
|
+
this.pool.subscribe('data-signals-' + this.roomId,
|
|
128
|
+
[{ kinds: [30078], '#p': [this.auth.pubkey], '#r': [this.roomId] }],
|
|
129
|
+
(event) => this._handleSignal(event));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
_onPresence(event) {
|
|
133
|
+
if (event.pubkey === this.auth.pubkey) return;
|
|
134
|
+
let data; try { data = JSON.parse(event.content); } catch { return; }
|
|
135
|
+
if (Date.now() - (data.ts || 0) > PRESENCE_EXPIRY) return;
|
|
136
|
+
const shortId = 'nostr-' + event.pubkey.slice(0, 12);
|
|
137
|
+
if (data.action === 'leave') { this.participants.delete(shortId); this._closePeer(event.pubkey); }
|
|
138
|
+
else if (!this.participants.has(shortId)) {
|
|
139
|
+
this.participants.set(shortId, { identity: data.name || event.pubkey.slice(0, 8), isLocal: false, connectionQuality: 'connecting' });
|
|
140
|
+
this._maybeConnect(event.pubkey);
|
|
141
|
+
} else if (!this.peers.has(event.pubkey)) this._maybeConnect(event.pubkey);
|
|
142
|
+
this._emit('participants', { list: this.getParticipants() });
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
_maybeConnect(peerPubkey) {
|
|
146
|
+
if (!peerPubkey || peerPubkey === this.auth.pubkey || this.peers.has(peerPubkey)) return;
|
|
147
|
+
this._cancelReconnect(peerPubkey);
|
|
148
|
+
const fsmActor = this.xstate.createActor(this.fsm.peerMachine);
|
|
149
|
+
fsmActor.subscribe((snap) => { const p = this.peers.get(peerPubkey); if (p) p.state = snap.value; });
|
|
150
|
+
fsmActor.start();
|
|
151
|
+
const peer = { pc: null, dc: null, pendingCandidates: [], bufferedCandidates: [], iceTimer: null, disconnectTimer: null, failCount: 0, state: 'new', fsm: fsmActor, remoteDescSet: false };
|
|
152
|
+
this.peers.set(peerPubkey, peer);
|
|
153
|
+
const pc = new RTCPeerConnection({ iceServers: this.iceServers, bundlePolicy: 'max-bundle' });
|
|
154
|
+
peer.pc = pc;
|
|
155
|
+
const isOfferer = this.auth.pubkey > peerPubkey;
|
|
156
|
+
this._wirePeer(peer, peerPubkey, fsmActor, isOfferer);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
_wirePeer(peer, peerPubkey, fsmActor, isOfferer) {
|
|
160
|
+
const pc = peer.pc;
|
|
161
|
+
pc.onicecandidate = (ev) => {
|
|
162
|
+
if (!ev.candidate) return;
|
|
163
|
+
peer.pendingCandidates.push(ev.candidate.toJSON());
|
|
164
|
+
if (peer.iceTimer) clearTimeout(peer.iceTimer);
|
|
165
|
+
peer.iceTimer = setTimeout(() => { if (peer.pendingCandidates.length) { this._publishSignal(peerPubkey, 'ice', peer.pendingCandidates.splice(0)); peer.iceTimer = null; } }, 500);
|
|
166
|
+
};
|
|
167
|
+
pc.onicegatheringstatechange = () => {
|
|
168
|
+
if (pc.iceGatheringState === 'complete') {
|
|
169
|
+
if (peer.iceTimer) { clearTimeout(peer.iceTimer); peer.iceTimer = null; }
|
|
170
|
+
if (peer.pendingCandidates.length) this._publishSignal(peerPubkey, 'ice', peer.pendingCandidates.splice(0));
|
|
171
|
+
}
|
|
172
|
+
};
|
|
173
|
+
pc.onconnectionstatechange = () => {
|
|
174
|
+
if (pc.connectionState === 'connected') {
|
|
175
|
+
peer.failCount = 0; this._cancelReconnect(peerPubkey);
|
|
176
|
+
if (fsmActor.getSnapshot().can({ type: 'recv_answer' })) fsmActor.send({ type: 'recv_answer' });
|
|
177
|
+
if (peer.disconnectTimer) { clearTimeout(peer.disconnectTimer); peer.disconnectTimer = null; }
|
|
178
|
+
}
|
|
179
|
+
if (pc.connectionState === 'disconnected') {
|
|
180
|
+
fsmActor.send({ type: 'disconnect' });
|
|
181
|
+
peer.disconnectTimer = setTimeout(() => this._doIceRestart(peer, peerPubkey, fsmActor), DISCONNECT_GRACE);
|
|
182
|
+
}
|
|
183
|
+
if (pc.connectionState === 'failed') this._doIceRestart(peer, peerPubkey, fsmActor);
|
|
184
|
+
if (pc.connectionState === 'closed') this._closePeer(peerPubkey);
|
|
185
|
+
};
|
|
186
|
+
if (isOfferer) {
|
|
187
|
+
try {
|
|
188
|
+
const dc = pc.createDataChannel(DC_LABEL, this.dcOptions);
|
|
189
|
+
peer.dc = dc; this._wireDataChannel(dc, peer, peerPubkey);
|
|
190
|
+
} catch {}
|
|
191
|
+
} else {
|
|
192
|
+
pc.ondatachannel = (ev) => {
|
|
193
|
+
if (ev.channel.label === DC_LABEL) { peer.dc = ev.channel; this._wireDataChannel(peer.dc, peer, peerPubkey); }
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
if (isOfferer) {
|
|
197
|
+
fsmActor.send({ type: 'offer' });
|
|
198
|
+
pc.createOffer().then(o => pc.setLocalDescription(o).then(() => this._publishSignal(peerPubkey, 'offer', o))).catch(() => {});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
_wireDataChannel(dc, peer, peerPubkey) {
|
|
203
|
+
dc.binaryType = 'arraybuffer';
|
|
204
|
+
dc.onopen = () => this._emit('peer-open', { peerPubkey });
|
|
205
|
+
dc.onclose = () => this._emit('peer-close', { peerPubkey });
|
|
206
|
+
dc.onmessage = (e) => this._emit('data', { peerPubkey, data: e.data });
|
|
207
|
+
dc.onerror = (e) => this._emit('peer-error', { peerPubkey, error: e });
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
_doIceRestart(peer, peerPubkey, fsmActor) {
|
|
211
|
+
const pc = peer.pc;
|
|
212
|
+
if (peer.disconnectTimer) { clearTimeout(peer.disconnectTimer); peer.disconnectTimer = null; }
|
|
213
|
+
peer.failCount++;
|
|
214
|
+
if (peer.failCount <= 1 && this.auth.pubkey > peerPubkey) {
|
|
215
|
+
fsmActor.send({ type: 'restart' }); pc.restartIce();
|
|
216
|
+
pc.createOffer({ iceRestart: true })
|
|
217
|
+
.then(o => pc.setLocalDescription(o).then(() => this._publishSignal(peerPubkey, 'offer', o)))
|
|
218
|
+
.catch(() => this._closePeer(peerPubkey));
|
|
219
|
+
} else { this._closePeer(peerPubkey); this._scheduleReconnect(peerPubkey, peer.failCount); }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
_handleSignal(event) {
|
|
223
|
+
const from = event.pubkey; if (from === this.auth.pubkey) return;
|
|
224
|
+
let data; try { data = JSON.parse(event.content); } catch { return; }
|
|
225
|
+
if (!data?.type) return;
|
|
226
|
+
if (!this.peers.has(from)) this._maybeConnect(from);
|
|
227
|
+
const peer = this.peers.get(from); if (!peer) return;
|
|
228
|
+
const pc = peer.pc; const fsmActor = peer.fsm;
|
|
229
|
+
const addCands = (cands) => cands.forEach(c => pc.addIceCandidate(new RTCIceCandidate(c)).catch(() => {}));
|
|
230
|
+
const drainBuf = () => { addCands(peer.bufferedCandidates); peer.bufferedCandidates = []; };
|
|
231
|
+
const doAnswer = async () => {
|
|
232
|
+
if (fsmActor.getSnapshot().can({ type: 'recv_offer' })) fsmActor.send({ type: 'recv_offer' });
|
|
233
|
+
await pc.setRemoteDescription(new RTCSessionDescription(data.data));
|
|
234
|
+
peer.remoteDescSet = true; drainBuf();
|
|
235
|
+
const a = await pc.createAnswer(); await pc.setLocalDescription(a);
|
|
236
|
+
fsmActor.send({ type: 'sent_answer' });
|
|
237
|
+
this._publishSignal(from, 'answer', a);
|
|
238
|
+
};
|
|
239
|
+
if (data.type === 'offer') {
|
|
240
|
+
const polite = this.auth.pubkey < from; const collision = pc.signalingState !== 'stable';
|
|
241
|
+
if (collision && !polite) return;
|
|
242
|
+
if (collision && polite) { pc.setLocalDescription({ type: 'rollback' }).then(doAnswer).catch(() => {}); return; }
|
|
243
|
+
doAnswer().catch(() => {});
|
|
244
|
+
} else if (data.type === 'answer' && pc.signalingState === 'have-local-offer') {
|
|
245
|
+
pc.setRemoteDescription(new RTCSessionDescription(data.data)).then(() => { peer.remoteDescSet = true; drainBuf(); }).catch(() => {});
|
|
246
|
+
} else if (data.type === 'ice') {
|
|
247
|
+
const cands = Array.isArray(data.data) ? data.data : [data.data];
|
|
248
|
+
if (peer.remoteDescSet) addCands(cands); else peer.bufferedCandidates.push(...cands);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async _publishSignal(toPubkey, type, data) {
|
|
253
|
+
if (!this.auth.pubkey || !this.roomId) return;
|
|
254
|
+
const d = 'wireweave-data-rtc:' + this.roomId + ':' + this.auth.pubkey + ':' + toPubkey + ':' + type + ':' + (type === 'ice' ? Date.now() : 'sdp');
|
|
255
|
+
const signed = await this.auth.sign({
|
|
256
|
+
kind: 30078, created_at: Math.floor(Date.now() / 1000),
|
|
257
|
+
tags: [['d', d], ['p', toPubkey], ['r', this.roomId]],
|
|
258
|
+
content: JSON.stringify({ type, data })
|
|
259
|
+
});
|
|
260
|
+
this.pool.publish(signed);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
_closePeer(peerPubkey) {
|
|
264
|
+
const peer = this.peers.get(peerPubkey); if (!peer) return;
|
|
265
|
+
if (peer.iceTimer) clearTimeout(peer.iceTimer);
|
|
266
|
+
if (peer.disconnectTimer) clearTimeout(peer.disconnectTimer);
|
|
267
|
+
try { peer.dc?.close(); } catch {}
|
|
268
|
+
try { peer.pc?.close(); } catch {}
|
|
269
|
+
this.peers.delete(peerPubkey);
|
|
270
|
+
this._emit('peer-closed', { peerPubkey });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
_cancelReconnect(pk) { const e = this.retrySchedule[pk]; if (e) { clearTimeout(e.timer); delete this.retrySchedule[pk]; } }
|
|
274
|
+
|
|
275
|
+
_scheduleReconnect(pk, attempt) {
|
|
276
|
+
const a = attempt || 0; if (a >= 6) return;
|
|
277
|
+
this._cancelReconnect(pk);
|
|
278
|
+
const timer = setTimeout(() => {
|
|
279
|
+
delete this.retrySchedule[pk];
|
|
280
|
+
if (!this.peers.has(pk) && this.roomId) this._maybeConnect(pk);
|
|
281
|
+
}, Math.min(2 ** a * 2000, 30000));
|
|
282
|
+
this.retrySchedule[pk] = { attempt: a, timer };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
debug() {
|
|
286
|
+
const peers = [];
|
|
287
|
+
for (const [pk, peer] of this.peers) {
|
|
288
|
+
peers.push({
|
|
289
|
+
pubkey: pk.slice(0, 12),
|
|
290
|
+
fsmState: peer.fsm?.getSnapshot().value,
|
|
291
|
+
connState: peer.pc?.connectionState,
|
|
292
|
+
dcState: peer.dc?.readyState || null,
|
|
293
|
+
candidates: peer.pendingCandidates.length,
|
|
294
|
+
buffered: peer.bufferedCandidates.length
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
return { fsm: this.actor?.getSnapshot().value, room: this.room, roomId: this.roomId, peers, participants: this.getParticipants(), retrySchedule: Object.keys(this.retrySchedule) };
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
_emit(t, d) { this.dispatchEvent(new CustomEvent(t, { detail: d })); }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export const createDataSession = (opts) => new DataSession(opts);
|
package/src/fsm.js
CHANGED
|
@@ -39,6 +39,16 @@ export const createFSM = (xstate) => {
|
|
|
39
39
|
error: { on: { reconnect: 'connecting' } }
|
|
40
40
|
}
|
|
41
41
|
}),
|
|
42
|
+
dataMachine: xstate.createMachine({
|
|
43
|
+
initial: 'idle',
|
|
44
|
+
states: {
|
|
45
|
+
idle: { on: { connect: 'connecting' } },
|
|
46
|
+
connecting: { on: { connected: 'connected', fail: 'idle' } },
|
|
47
|
+
connected: { on: { disconnect: 'disconnecting', degrade: 'reconnecting' } },
|
|
48
|
+
reconnecting: { on: { connected: 'connected', disconnect: 'disconnecting', fail: 'idle' } },
|
|
49
|
+
disconnecting: { on: { done: 'idle' } }
|
|
50
|
+
}
|
|
51
|
+
}),
|
|
42
52
|
sfuMachine: xstate.createMachine({
|
|
43
53
|
initial: 'mesh',
|
|
44
54
|
states: {
|
package/src/index.js
CHANGED
|
@@ -2,6 +2,7 @@ export { RelayPool, createRelayPool } from './relay-pool.js';
|
|
|
2
2
|
export { NostrAuth, createAuth } from './auth.js';
|
|
3
3
|
export { createFSM } from './fsm.js';
|
|
4
4
|
export { VoiceSession, createVoiceSession } from './voice.js';
|
|
5
|
+
export { DataSession, createDataSession } from './data.js';
|
|
5
6
|
export { Chat, createChat } from './chat.js';
|
|
6
7
|
export { Channels, createChannels } from './channels.js';
|
|
7
8
|
export { Servers, createServers } from './servers.js';
|
package/src/relay-pool.js
CHANGED
|
@@ -2,7 +2,13 @@ const DEFAULT_RELAYS = [
|
|
|
2
2
|
'wss://relay.damus.io',
|
|
3
3
|
'wss://relay.primal.net',
|
|
4
4
|
'wss://nos.lol',
|
|
5
|
-
'wss://relay.snort.social'
|
|
5
|
+
'wss://relay.snort.social',
|
|
6
|
+
'wss://relay.nostr.band',
|
|
7
|
+
'wss://nostr.wine',
|
|
8
|
+
'wss://relay.current.fyi',
|
|
9
|
+
'wss://offchain.pub',
|
|
10
|
+
'wss://nostr-pub.wellorder.net',
|
|
11
|
+
'wss://relay.0xchat.com'
|
|
6
12
|
];
|
|
7
13
|
|
|
8
14
|
const trim = (set, max, keep) => {
|
package/src/wireweave.js
CHANGED
|
@@ -18,7 +18,7 @@ export const createWireweave = ({
|
|
|
18
18
|
xstate,
|
|
19
19
|
storage = (typeof localStorage !== 'undefined' ? localStorage : null),
|
|
20
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'],
|
|
21
|
+
relays = ['wss://relay.damus.io', 'wss://relay.primal.net', 'wss://nos.lol', 'wss://relay.snort.social', 'wss://relay.nostr.band', 'wss://nostr.wine', 'wss://offchain.pub'],
|
|
22
22
|
mediaDevices = (typeof navigator !== 'undefined' ? navigator.mediaDevices : null),
|
|
23
23
|
WebSocketImpl = (typeof WebSocket !== 'undefined' ? WebSocket : null)
|
|
24
24
|
} = {}) => {
|