@leofcoin/peernet 1.2.20 → 1.2.21
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/exports/browser/{browser-DTwbEd2v.js → browser-8BFql6K9.js} +1 -1
- package/exports/browser/{browser-AkMNdace.js → browser-FVp_QbaL.js} +1 -1
- package/exports/browser/client-lPe0SUWx.js +2165 -0
- package/exports/browser/identity-BeVZQ2Dp.js +8347 -0
- package/exports/browser/identity.js +2 -1
- package/exports/browser/{index-C8MlQb3B.js → index-D6qd-AUn.js} +2 -1
- package/exports/browser/{messages-skO3uGkB.js → messages-BfDvXW-x.js} +3 -2
- package/exports/browser/{peernet-Ch6Gs5XY.js → peernet-DFiLLjUD.js} +3 -3
- package/exports/browser/peernet.js +3 -2
- package/package.json +30 -17
- package/tsconfig.json +1 -0
- package/exports/browser/client-B5W9RVIn.js +0 -1047
- package/exports/browser/identity-B8_RBemH.js +0 -17530
- package/exports/browser/index-avzvc6cK.js +0 -7595
- /package/exports/types/{src/dht → dht}/dht.d.ts +0 -0
- /package/exports/types/{src/discovery → discovery}/peer-discovery.d.ts +0 -0
- /package/exports/types/{src/errors → errors}/errors.d.ts +0 -0
- /package/exports/types/{src/handlers → handlers}/data.d.ts +0 -0
- /package/exports/types/{src/handlers → handlers}/message.d.ts +0 -0
- /package/exports/types/{src/identity.d.ts → identity.d.ts} +0 -0
- /package/exports/types/{src/messages → messages}/chat.d.ts +0 -0
- /package/exports/types/{src/messages → messages}/data-response.d.ts +0 -0
- /package/exports/types/{src/messages → messages}/data.d.ts +0 -0
- /package/exports/types/{src/messages → messages}/dht-response.d.ts +0 -0
- /package/exports/types/{src/messages → messages}/dht.d.ts +0 -0
- /package/exports/types/{src/messages → messages}/file-link.d.ts +0 -0
- /package/exports/types/{src/messages → messages}/file.d.ts +0 -0
- /package/exports/types/{src/messages → messages}/peer-response.d.ts +0 -0
- /package/exports/types/{src/messages → messages}/peer.d.ts +0 -0
- /package/exports/types/{src/messages → messages}/peernet.d.ts +0 -0
- /package/exports/types/{src/messages → messages}/ps.d.ts +0 -0
- /package/exports/types/{src/messages → messages}/request.d.ts +0 -0
- /package/exports/types/{src/messages → messages}/response.d.ts +0 -0
- /package/exports/types/{src/messages.d.ts → messages.d.ts} +0 -0
- /package/exports/types/{src/peer-info.d.ts → peer-info.d.ts} +0 -0
- /package/exports/types/{src/peernet.d.ts → peernet.d.ts} +0 -0
- /package/exports/types/{src/prompts → prompts}/password/browser.d.ts +0 -0
- /package/exports/types/{src/prompts → prompts}/password/node.d.ts +0 -0
- /package/exports/types/{src/prompts → prompts}/password.d.ts +0 -0
- /package/exports/types/{src/proto → proto}/chat-message.proto.d.ts +0 -0
- /package/exports/types/{src/proto → proto}/data-response.proto.d.ts +0 -0
- /package/exports/types/{src/proto → proto}/data.proto.d.ts +0 -0
- /package/exports/types/{src/proto → proto}/dht-response.proto.d.ts +0 -0
- /package/exports/types/{src/proto → proto}/dht.proto.d.ts +0 -0
- /package/exports/types/{src/proto → proto}/file-link.proto.d.ts +0 -0
- /package/exports/types/{src/proto → proto}/file.proto.d.ts +0 -0
- /package/exports/types/{src/proto → proto}/peer-response.proto.d.ts +0 -0
- /package/exports/types/{src/proto → proto}/peer.proto.d.ts +0 -0
- /package/exports/types/{src/proto → proto}/peernet.proto.d.ts +0 -0
- /package/exports/types/{src/proto → proto}/ps.proto.d.ts +0 -0
- /package/exports/types/{src/proto → proto}/request.proto.d.ts +0 -0
- /package/exports/types/{src/proto → proto}/response.proto.d.ts +0 -0
- /package/exports/types/{src/types.d.ts → types.d.ts} +0 -0
- /package/exports/types/{src/utils → utils}/utils.d.ts +0 -0
|
@@ -0,0 +1,2165 @@
|
|
|
1
|
+
import { L as LittlePubSub, d as deflate_1, i as inflate_1, c as createDebugger } from './peernet-DFiLLjUD.js';
|
|
2
|
+
import './identity-BeVZQ2Dp.js';
|
|
3
|
+
import 'crypto';
|
|
4
|
+
import './value-C3vAp-wb.js';
|
|
5
|
+
|
|
6
|
+
class Api {
|
|
7
|
+
_pubsub;
|
|
8
|
+
constructor(_pubsub) {
|
|
9
|
+
this._pubsub = _pubsub;
|
|
10
|
+
}
|
|
11
|
+
subscribe(topic, cb) {
|
|
12
|
+
this._pubsub.subscribe(topic, cb);
|
|
13
|
+
}
|
|
14
|
+
unsubscribe(topic, cb) {
|
|
15
|
+
this._pubsub.unsubscribe(topic, cb);
|
|
16
|
+
}
|
|
17
|
+
publish(topic, value) {
|
|
18
|
+
this._pubsub.publish(topic, value);
|
|
19
|
+
}
|
|
20
|
+
subscribers() {
|
|
21
|
+
this._pubsub.subscribers;
|
|
22
|
+
}
|
|
23
|
+
connectionState(state) {
|
|
24
|
+
switch (state) {
|
|
25
|
+
case 0:
|
|
26
|
+
return 'connecting';
|
|
27
|
+
case 1:
|
|
28
|
+
return 'open';
|
|
29
|
+
case 2:
|
|
30
|
+
return 'closing';
|
|
31
|
+
case 3:
|
|
32
|
+
return 'closed';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* @param {string} type
|
|
37
|
+
* @param {string} name
|
|
38
|
+
* @param {object} params
|
|
39
|
+
*/
|
|
40
|
+
request(client, request) {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
const state = this.connectionState(client.readyState);
|
|
43
|
+
if (state !== 'open')
|
|
44
|
+
return reject(`coudn't send request to ${client.id}, no open connection found.`);
|
|
45
|
+
request.id = Math.random().toString(36).slice(-12);
|
|
46
|
+
const handler = (result) => {
|
|
47
|
+
if (result && result.error)
|
|
48
|
+
return reject(result.error);
|
|
49
|
+
resolve({ result, id: request.id, handler });
|
|
50
|
+
this.unsubscribe(request.id, handler);
|
|
51
|
+
};
|
|
52
|
+
this.subscribe(request.id, handler);
|
|
53
|
+
this.send(client, request);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
async send(client, request) {
|
|
57
|
+
return client.send(JSON.stringify(request));
|
|
58
|
+
}
|
|
59
|
+
pubsub(client) {
|
|
60
|
+
return {
|
|
61
|
+
publish: (topic = 'pubsub', value) => {
|
|
62
|
+
return this.send(client, { url: 'pubsub', params: { topic, value } });
|
|
63
|
+
},
|
|
64
|
+
subscribe: (topic = 'pubsub', cb) => {
|
|
65
|
+
this.subscribe(topic, cb);
|
|
66
|
+
return this.send(client, {
|
|
67
|
+
url: 'pubsub',
|
|
68
|
+
params: { topic, subscribe: true }
|
|
69
|
+
});
|
|
70
|
+
},
|
|
71
|
+
unsubscribe: (topic = 'pubsub', cb) => {
|
|
72
|
+
this.unsubscribe(topic, cb);
|
|
73
|
+
return this.send(client, {
|
|
74
|
+
url: 'pubsub',
|
|
75
|
+
params: { topic, unsubscribe: true }
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
subscribers: this._pubsub.subscribers
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
server(client) {
|
|
82
|
+
return {
|
|
83
|
+
uptime: async () => {
|
|
84
|
+
try {
|
|
85
|
+
const { result, id, handler } = await this.request(client, {
|
|
86
|
+
url: 'uptime'
|
|
87
|
+
});
|
|
88
|
+
this.unsubscribe(id, handler);
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
catch (e) {
|
|
92
|
+
throw e;
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
ping: async () => {
|
|
96
|
+
try {
|
|
97
|
+
const now = new Date().getTime();
|
|
98
|
+
const { result, id, handler } = await this.request(client, {
|
|
99
|
+
url: 'ping'
|
|
100
|
+
});
|
|
101
|
+
this.unsubscribe(id, handler);
|
|
102
|
+
return Number(result) - now;
|
|
103
|
+
}
|
|
104
|
+
catch (e) {
|
|
105
|
+
throw e;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
peernet(client) {
|
|
111
|
+
return {
|
|
112
|
+
join: async (params) => {
|
|
113
|
+
try {
|
|
114
|
+
params.join = true;
|
|
115
|
+
const requested = { url: 'peernet', params };
|
|
116
|
+
const { result, id, handler } = await this.request(client, requested);
|
|
117
|
+
this.unsubscribe(id, handler);
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
catch (e) {
|
|
121
|
+
throw e;
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
leave: async (params) => {
|
|
125
|
+
try {
|
|
126
|
+
params.join = false;
|
|
127
|
+
const requested = { url: 'peernet', params };
|
|
128
|
+
const { result, id, handler } = await this.request(client, requested);
|
|
129
|
+
this.unsubscribe(id, handler);
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
catch (e) {
|
|
133
|
+
throw e;
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
peers: async () => {
|
|
137
|
+
try {
|
|
138
|
+
const requested = { url: 'peernet', params: { peers: true } };
|
|
139
|
+
const { result, id, handler } = await this.request(client, requested);
|
|
140
|
+
this.unsubscribe(id, handler);
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
catch (e) {
|
|
144
|
+
throw e;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
class ClientConnection {
|
|
152
|
+
client;
|
|
153
|
+
api;
|
|
154
|
+
#startTime;
|
|
155
|
+
constructor(client, api) {
|
|
156
|
+
this.#startTime = new Date().getTime();
|
|
157
|
+
this.client = client;
|
|
158
|
+
this.api = api;
|
|
159
|
+
}
|
|
160
|
+
request = async (req) => {
|
|
161
|
+
const { result, id, handler } = await this.api.request(this.client, req);
|
|
162
|
+
globalThis.pubsub.unsubscribe(id, handler);
|
|
163
|
+
return result;
|
|
164
|
+
};
|
|
165
|
+
send = (req) => this.api.send(this.client, req);
|
|
166
|
+
get subscribe() {
|
|
167
|
+
return this.api.subscribe;
|
|
168
|
+
}
|
|
169
|
+
get unsubscribe() {
|
|
170
|
+
return this.api.unsubscribe;
|
|
171
|
+
}
|
|
172
|
+
get subscribers() {
|
|
173
|
+
return this.api.subscribers;
|
|
174
|
+
}
|
|
175
|
+
get publish() {
|
|
176
|
+
return this.api.publish;
|
|
177
|
+
}
|
|
178
|
+
get pubsub() {
|
|
179
|
+
return this.api.pubsub(this.client);
|
|
180
|
+
}
|
|
181
|
+
uptime = () => {
|
|
182
|
+
const now = new Date().getTime();
|
|
183
|
+
return (now - this.#startTime);
|
|
184
|
+
};
|
|
185
|
+
get peernet() {
|
|
186
|
+
return this.api.peernet(this.client);
|
|
187
|
+
}
|
|
188
|
+
get server() {
|
|
189
|
+
return this.api.server(this.client);
|
|
190
|
+
}
|
|
191
|
+
connectionState = () => this.api.connectionState(this.client.readyState);
|
|
192
|
+
close = exit => {
|
|
193
|
+
// client.onclose = message => {
|
|
194
|
+
// if (exit) process.exit()
|
|
195
|
+
// }
|
|
196
|
+
this.client.close();
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (!globalThis.PubSub)
|
|
201
|
+
globalThis.PubSub = LittlePubSub;
|
|
202
|
+
if (!globalThis.pubsub)
|
|
203
|
+
globalThis.pubsub = new LittlePubSub(false);
|
|
204
|
+
class SocketRequestClient {
|
|
205
|
+
api;
|
|
206
|
+
clientConnection;
|
|
207
|
+
#tries = 0;
|
|
208
|
+
#retry = false;
|
|
209
|
+
#timeout = 10_000;
|
|
210
|
+
#times = 10;
|
|
211
|
+
#options;
|
|
212
|
+
#protocol;
|
|
213
|
+
#url;
|
|
214
|
+
#experimentalWebsocket = false;
|
|
215
|
+
constructor(url, protocol, options) {
|
|
216
|
+
let { retry, timeout, times, experimentalWebsocket } = options || {};
|
|
217
|
+
if (retry !== undefined)
|
|
218
|
+
this.#retry = retry;
|
|
219
|
+
if (timeout !== undefined)
|
|
220
|
+
this.#timeout = timeout;
|
|
221
|
+
if (times !== undefined)
|
|
222
|
+
this.#times = times;
|
|
223
|
+
if (experimentalWebsocket !== undefined)
|
|
224
|
+
this.#experimentalWebsocket = experimentalWebsocket;
|
|
225
|
+
this.#url = url;
|
|
226
|
+
this.#protocol = protocol;
|
|
227
|
+
this.#options = options;
|
|
228
|
+
this.api = new Api(globalThis.pubsub);
|
|
229
|
+
}
|
|
230
|
+
init() {
|
|
231
|
+
return new Promise(async (resolve, reject) => {
|
|
232
|
+
const init = async () => {
|
|
233
|
+
// @ts-ignore
|
|
234
|
+
if (!globalThis.WebSocket && !this.#experimentalWebsocket)
|
|
235
|
+
globalThis.WebSocket = (await import('./browser-8BFql6K9.js').then(function (n) { return n.b; })).default.w3cwebsocket;
|
|
236
|
+
const client = new WebSocket(this.#url, this.#protocol);
|
|
237
|
+
if (this.#experimentalWebsocket) {
|
|
238
|
+
client.addEventListener('error', this.onerror);
|
|
239
|
+
client.addEventListener('message', this.onmessage);
|
|
240
|
+
client.addEventListener('open', () => {
|
|
241
|
+
this.#tries = 0;
|
|
242
|
+
resolve(new ClientConnection(client, this.api));
|
|
243
|
+
});
|
|
244
|
+
client.addEventListener('close', (client.onclose = (message) => {
|
|
245
|
+
this.#tries++;
|
|
246
|
+
if (!this.#retry)
|
|
247
|
+
return reject(this.#options);
|
|
248
|
+
if (this.#tries > this.#times) {
|
|
249
|
+
console.log(`${this.#options.protocol} Client Closed`);
|
|
250
|
+
console.error(`could not connect to - ${this.#url}/`);
|
|
251
|
+
return resolve(new ClientConnection(client, this.api));
|
|
252
|
+
}
|
|
253
|
+
if (message.code === 1006) {
|
|
254
|
+
console.log(`Retrying in ${this.#timeout} ms`);
|
|
255
|
+
setTimeout(() => {
|
|
256
|
+
return init();
|
|
257
|
+
}, this.#timeout);
|
|
258
|
+
}
|
|
259
|
+
}));
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
client.onmessage = this.onmessage;
|
|
263
|
+
client.onerror = this.onerror;
|
|
264
|
+
client.onopen = () => {
|
|
265
|
+
this.#tries = 0;
|
|
266
|
+
resolve(new ClientConnection(client, this.api));
|
|
267
|
+
};
|
|
268
|
+
client.onclose = (message) => {
|
|
269
|
+
this.#tries++;
|
|
270
|
+
if (!this.#retry)
|
|
271
|
+
return reject(this.#options);
|
|
272
|
+
if (this.#tries > this.#times) {
|
|
273
|
+
console.log(`${this.#options.protocol} Client Closed`);
|
|
274
|
+
console.error(`could not connect to - ${this.#url}/`);
|
|
275
|
+
return resolve(new ClientConnection(client, this.api));
|
|
276
|
+
}
|
|
277
|
+
if (message.code === 1006) {
|
|
278
|
+
console.log(`Retrying in ${this.#timeout} ms`);
|
|
279
|
+
setTimeout(() => {
|
|
280
|
+
return init();
|
|
281
|
+
}, this.#timeout);
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
};
|
|
286
|
+
return init();
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
onerror = (error) => {
|
|
290
|
+
if (globalThis.pubsub.hasSubscribers('error')) {
|
|
291
|
+
globalThis.pubsub.publish('error', error);
|
|
292
|
+
}
|
|
293
|
+
else {
|
|
294
|
+
console.error(error);
|
|
295
|
+
}
|
|
296
|
+
};
|
|
297
|
+
onmessage(message) {
|
|
298
|
+
if (!message.data) {
|
|
299
|
+
console.warn(`message ignored because it contained no data`);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
const { value, url, status, id } = JSON.parse(message.data.toString());
|
|
303
|
+
const publisher = id ? id : url;
|
|
304
|
+
if (status === 200) {
|
|
305
|
+
globalThis.pubsub.publish(publisher, value);
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
globalThis.pubsub.publish(publisher, { error: value });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const MAX_MESSAGE_SIZE = 16000;
|
|
314
|
+
const defaultOptions = {
|
|
315
|
+
networkVersion: 'peach',
|
|
316
|
+
version: 'v1',
|
|
317
|
+
stars: ['wss://star.leofcoin.org'],
|
|
318
|
+
connectEvent: 'peer:connected'
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
const crc32$1 = (data) => {
|
|
322
|
+
let crc = 0xffffffff;
|
|
323
|
+
for (let i = 0; i < data.length; i++) {
|
|
324
|
+
crc ^= data[i];
|
|
325
|
+
for (let j = 0; j < 8; j++) {
|
|
326
|
+
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
330
|
+
};
|
|
331
|
+
class PeerCore {
|
|
332
|
+
transport;
|
|
333
|
+
options;
|
|
334
|
+
constructor(transport, options) {
|
|
335
|
+
this.transport = transport;
|
|
336
|
+
this.options = options;
|
|
337
|
+
}
|
|
338
|
+
#createMessageId() {
|
|
339
|
+
const randomUUID = globalThis.crypto?.randomUUID;
|
|
340
|
+
if (typeof randomUUID === 'function')
|
|
341
|
+
return randomUUID.call(globalThis.crypto);
|
|
342
|
+
return `msg-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
343
|
+
}
|
|
344
|
+
#encodeFrame(id, totalSize, index, count, payload, flags) {
|
|
345
|
+
const te = new TextEncoder();
|
|
346
|
+
const idBytes = te.encode(id);
|
|
347
|
+
const crc = crc32$1(payload);
|
|
348
|
+
const headerLen = 1 + 1 + 4 + 4 + 4 + 4 + 2 + idBytes.length;
|
|
349
|
+
const buffer = new ArrayBuffer(headerLen + payload.length);
|
|
350
|
+
const view = new DataView(buffer);
|
|
351
|
+
const out = new Uint8Array(buffer);
|
|
352
|
+
let offset = 0;
|
|
353
|
+
view.setUint8(offset, 1);
|
|
354
|
+
offset += 1;
|
|
355
|
+
view.setUint8(offset, flags);
|
|
356
|
+
offset += 1;
|
|
357
|
+
view.setUint32(offset, totalSize, true);
|
|
358
|
+
offset += 4;
|
|
359
|
+
view.setUint32(offset, index, true);
|
|
360
|
+
offset += 4;
|
|
361
|
+
view.setUint32(offset, count, true);
|
|
362
|
+
offset += 4;
|
|
363
|
+
view.setUint32(offset, crc, true);
|
|
364
|
+
offset += 4;
|
|
365
|
+
view.setUint16(offset, idBytes.length, true);
|
|
366
|
+
offset += 2;
|
|
367
|
+
out.set(idBytes, offset);
|
|
368
|
+
offset += idBytes.length;
|
|
369
|
+
out.set(payload, offset);
|
|
370
|
+
return out;
|
|
371
|
+
}
|
|
372
|
+
async #chunkAndSend(data, id) {
|
|
373
|
+
if (!this.transport.isConnected())
|
|
374
|
+
return;
|
|
375
|
+
this.options.onPayloadSend?.(data.length);
|
|
376
|
+
let sendData = data;
|
|
377
|
+
try {
|
|
378
|
+
const compressed = deflate_1(data);
|
|
379
|
+
if (compressed?.length &&
|
|
380
|
+
compressed.length < data.length * this.options.compressionThreshold) {
|
|
381
|
+
sendData = compressed;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
catch (e) { }
|
|
385
|
+
const size = sendData.length;
|
|
386
|
+
if (size <= this.options.maxMessageSize) {
|
|
387
|
+
if (!this.transport.isConnected())
|
|
388
|
+
return;
|
|
389
|
+
const flags = (sendData !== data ? 1 : 0) << 1;
|
|
390
|
+
this.transport.sendRaw(this.#encodeFrame(id, size, 0, 1, sendData, flags));
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const threshold = 4 * 1024 * 1024;
|
|
394
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
395
|
+
const count = Math.ceil(size / this.options.maxMessageSize);
|
|
396
|
+
const flags = (1 << 0) | ((sendData !== data ? 1 : 0) << 1);
|
|
397
|
+
let index = 0;
|
|
398
|
+
let remaining = sendData;
|
|
399
|
+
while (remaining.length !== 0) {
|
|
400
|
+
const amountToSlice = remaining.length >= this.options.maxMessageSize
|
|
401
|
+
? this.options.maxMessageSize
|
|
402
|
+
: remaining.length;
|
|
403
|
+
const chunk = remaining.subarray(0, amountToSlice);
|
|
404
|
+
remaining = remaining.subarray(amountToSlice);
|
|
405
|
+
while ((this.options.getBufferedAmount?.() || 0) > threshold) {
|
|
406
|
+
if (!this.transport.isConnected())
|
|
407
|
+
return;
|
|
408
|
+
// eslint-disable-next-line no-await-in-loop
|
|
409
|
+
await sleep(10);
|
|
410
|
+
}
|
|
411
|
+
if (!this.transport.isConnected())
|
|
412
|
+
return;
|
|
413
|
+
this.transport.sendRaw(this.#encodeFrame(id, size, index, count, chunk, flags));
|
|
414
|
+
index += 1;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
send(data, id = this.#createMessageId()) {
|
|
418
|
+
void this.#chunkAndSend(data, id).catch(() => { });
|
|
419
|
+
}
|
|
420
|
+
request(data, id = this.#createMessageId()) {
|
|
421
|
+
return new Promise((resolve, reject) => {
|
|
422
|
+
if (!this.transport.isConnected()) {
|
|
423
|
+
reject(new Error('peer is not connected'));
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
const pubsub = this.options.getPubSub();
|
|
427
|
+
if (!pubsub) {
|
|
428
|
+
reject(new Error('globalThis.pubsub is not available'));
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
let timeout;
|
|
432
|
+
let settled = false;
|
|
433
|
+
const finish = (fn) => {
|
|
434
|
+
if (settled)
|
|
435
|
+
return;
|
|
436
|
+
settled = true;
|
|
437
|
+
clearTimeout(timeout);
|
|
438
|
+
try {
|
|
439
|
+
pubsub.unsubscribe(id, onrequest);
|
|
440
|
+
}
|
|
441
|
+
catch (e) { }
|
|
442
|
+
fn();
|
|
443
|
+
};
|
|
444
|
+
const onrequest = ({ data }) => {
|
|
445
|
+
finish(() => resolve(data));
|
|
446
|
+
};
|
|
447
|
+
timeout = setTimeout(() => {
|
|
448
|
+
finish(() => reject(new Error(`request for ${id} timed out`)));
|
|
449
|
+
}, 30_000);
|
|
450
|
+
try {
|
|
451
|
+
pubsub.subscribe(id, onrequest);
|
|
452
|
+
}
|
|
453
|
+
catch (error) {
|
|
454
|
+
finish(() => {
|
|
455
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
456
|
+
});
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
if (!this.transport.isConnected()) {
|
|
460
|
+
finish(() => reject(new Error('peer disconnected before request send')));
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
void this.#chunkAndSend(data, id).catch((error) => {
|
|
464
|
+
finish(() => {
|
|
465
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
const iceServers = [
|
|
473
|
+
{
|
|
474
|
+
urls: 'stun:stun.l.google.com:19302' // Google's public STUN server
|
|
475
|
+
},
|
|
476
|
+
{
|
|
477
|
+
urls: 'stun:openrelay.metered.ca:80'
|
|
478
|
+
},
|
|
479
|
+
{
|
|
480
|
+
urls: 'turn:openrelay.metered.ca:443',
|
|
481
|
+
username: 'openrelayproject',
|
|
482
|
+
credential: 'openrelayproject'
|
|
483
|
+
},
|
|
484
|
+
{
|
|
485
|
+
urls: 'turn:openrelay.metered.ca:443?transport=tcp',
|
|
486
|
+
username: 'openrelayproject',
|
|
487
|
+
credential: 'openrelayproject'
|
|
488
|
+
}
|
|
489
|
+
];
|
|
490
|
+
class WebRTCPeer {
|
|
491
|
+
#pc;
|
|
492
|
+
#listeners = new Map();
|
|
493
|
+
#pendingCandidates = [];
|
|
494
|
+
#connected = false;
|
|
495
|
+
#destroyed = false;
|
|
496
|
+
#trickle = true;
|
|
497
|
+
#core;
|
|
498
|
+
_pc;
|
|
499
|
+
_channel = null;
|
|
500
|
+
initiator;
|
|
501
|
+
peerId;
|
|
502
|
+
channelName;
|
|
503
|
+
version;
|
|
504
|
+
compressionThreshold = 0.98;
|
|
505
|
+
bw = { up: 0, down: 0 };
|
|
506
|
+
get connected() {
|
|
507
|
+
return this.#connected;
|
|
508
|
+
}
|
|
509
|
+
constructor(options) {
|
|
510
|
+
const { from, to, initiator, trickle, config, version, wrtc, compressionThreshold } = options;
|
|
511
|
+
const rtc = this.#resolveRtc(wrtc);
|
|
512
|
+
const channelName = initiator ? `${from}:${to}` : `${to}:${from}`;
|
|
513
|
+
const pc = new rtc.RTCPeerConnection({ iceServers, ...config });
|
|
514
|
+
this.#pc = pc;
|
|
515
|
+
this._pc = pc;
|
|
516
|
+
this.initiator = Boolean(initiator);
|
|
517
|
+
this.#trickle = trickle ?? true;
|
|
518
|
+
this.version = String(version);
|
|
519
|
+
this.peerId = to;
|
|
520
|
+
this.channelName = channelName;
|
|
521
|
+
if (compressionThreshold !== undefined)
|
|
522
|
+
this.compressionThreshold = compressionThreshold;
|
|
523
|
+
this.#setupPeerConnectionHandlers(rtc);
|
|
524
|
+
if (this.initiator) {
|
|
525
|
+
this.#attachDataChannel(this.#pc.createDataChannel(channelName));
|
|
526
|
+
void this.#createOffer();
|
|
527
|
+
}
|
|
528
|
+
this.#core = new PeerCore({
|
|
529
|
+
kind: 'webrtc-datachannel',
|
|
530
|
+
isConnected: () => this.connected,
|
|
531
|
+
sendRaw: (payload) => this.#sendRaw(payload)
|
|
532
|
+
}, {
|
|
533
|
+
maxMessageSize: MAX_MESSAGE_SIZE,
|
|
534
|
+
compressionThreshold: this.compressionThreshold,
|
|
535
|
+
getBufferedAmount: () => this._channel?.bufferedAmount || 0,
|
|
536
|
+
getPubSub: this.#getPubSub,
|
|
537
|
+
onPayloadSend: (payloadBytes) => {
|
|
538
|
+
this.bw.up += payloadBytes;
|
|
539
|
+
}
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
#createMessageId() {
|
|
543
|
+
const randomUUID = globalThis.crypto?.randomUUID;
|
|
544
|
+
if (typeof randomUUID === 'function')
|
|
545
|
+
return randomUUID.call(globalThis.crypto);
|
|
546
|
+
return `msg-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
547
|
+
}
|
|
548
|
+
#resolveRtc(wrtc) {
|
|
549
|
+
if (wrtc)
|
|
550
|
+
return wrtc;
|
|
551
|
+
const globalCandidate = globalThis;
|
|
552
|
+
if (globalCandidate.RTCPeerConnection) {
|
|
553
|
+
return {
|
|
554
|
+
RTCPeerConnection: globalCandidate.RTCPeerConnection,
|
|
555
|
+
RTCSessionDescription: globalCandidate.RTCSessionDescription,
|
|
556
|
+
RTCIceCandidate: globalCandidate.RTCIceCandidate
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
if (globalCandidate.wrtc?.RTCPeerConnection)
|
|
560
|
+
return globalCandidate.wrtc;
|
|
561
|
+
throw new Error('No WebRTC implementation available');
|
|
562
|
+
}
|
|
563
|
+
#setupPeerConnectionHandlers(rtc) {
|
|
564
|
+
this.#pc.onicecandidate = (event) => {
|
|
565
|
+
if (this.#destroyed)
|
|
566
|
+
return;
|
|
567
|
+
if (event.candidate && this.#trickle) {
|
|
568
|
+
const payload = event.candidate.toJSON
|
|
569
|
+
? event.candidate.toJSON()
|
|
570
|
+
: {
|
|
571
|
+
candidate: event.candidate.candidate,
|
|
572
|
+
sdpMLineIndex: event.candidate.sdpMLineIndex,
|
|
573
|
+
sdpMid: event.candidate.sdpMid,
|
|
574
|
+
usernameFragment: event.candidate.usernameFragment
|
|
575
|
+
};
|
|
576
|
+
this.#emit('signal', payload);
|
|
577
|
+
}
|
|
578
|
+
if (!event.candidate && !this.#trickle && this.#pc.localDescription) {
|
|
579
|
+
this.#emit('signal', this.#serializeDescription(this.#pc.localDescription));
|
|
580
|
+
}
|
|
581
|
+
};
|
|
582
|
+
this.#pc.onconnectionstatechange = () => {
|
|
583
|
+
const state = this.#pc.connectionState;
|
|
584
|
+
if (state === 'failed' || state === 'closed')
|
|
585
|
+
this.destroy();
|
|
586
|
+
};
|
|
587
|
+
this.#pc.ondatachannel = (event) => {
|
|
588
|
+
if (!this._channel)
|
|
589
|
+
this.#attachDataChannel(event.channel);
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
#attachDataChannel(channel) {
|
|
593
|
+
this._channel = channel;
|
|
594
|
+
channel.binaryType = 'arraybuffer';
|
|
595
|
+
channel.onopen = () => {
|
|
596
|
+
if (this.#destroyed || this.#connected)
|
|
597
|
+
return;
|
|
598
|
+
this.#connected = true;
|
|
599
|
+
this.#emit('connect');
|
|
600
|
+
};
|
|
601
|
+
channel.onclose = () => {
|
|
602
|
+
if (this.#destroyed)
|
|
603
|
+
return;
|
|
604
|
+
this.destroy();
|
|
605
|
+
};
|
|
606
|
+
channel.onerror = () => {
|
|
607
|
+
this.#emit('error', new Error('DataChannel error'));
|
|
608
|
+
};
|
|
609
|
+
channel.onmessage = (event) => {
|
|
610
|
+
this.#emit('data', event.data);
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
async #createOffer() {
|
|
614
|
+
try {
|
|
615
|
+
const offer = await this.#pc.createOffer();
|
|
616
|
+
await this.#pc.setLocalDescription(offer);
|
|
617
|
+
if (this.#trickle && this.#pc.localDescription) {
|
|
618
|
+
this.#emit('signal', this.#serializeDescription(this.#pc.localDescription));
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
catch (error) {
|
|
622
|
+
this.#emit('error', error instanceof Error ? error : new Error(String(error)));
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
async #applyRemoteDescription(rtc, description) {
|
|
626
|
+
const sessionDescription = rtc.RTCSessionDescription
|
|
627
|
+
? new rtc.RTCSessionDescription(description)
|
|
628
|
+
: description;
|
|
629
|
+
await this.#pc.setRemoteDescription(sessionDescription);
|
|
630
|
+
if (description.type === 'offer') {
|
|
631
|
+
const answer = await this.#pc.createAnswer();
|
|
632
|
+
await this.#pc.setLocalDescription(answer);
|
|
633
|
+
if (this.#pc.localDescription) {
|
|
634
|
+
this.#emit('signal', this.#serializeDescription(this.#pc.localDescription));
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
const pending = [...this.#pendingCandidates];
|
|
638
|
+
this.#pendingCandidates = [];
|
|
639
|
+
for (const candidate of pending) {
|
|
640
|
+
// eslint-disable-next-line no-await-in-loop
|
|
641
|
+
await this.#addIceCandidate(rtc, candidate);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
async #addIceCandidate(rtc, candidate) {
|
|
645
|
+
if (!this.#pc.remoteDescription) {
|
|
646
|
+
this.#pendingCandidates.push(candidate);
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
const iceCandidate = rtc.RTCIceCandidate
|
|
650
|
+
? new rtc.RTCIceCandidate(candidate)
|
|
651
|
+
: candidate;
|
|
652
|
+
await this.#pc.addIceCandidate(iceCandidate);
|
|
653
|
+
}
|
|
654
|
+
#serializeDescription(description) {
|
|
655
|
+
return {
|
|
656
|
+
type: description.type,
|
|
657
|
+
sdp: description.sdp || ''
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
#sendRaw(payload) {
|
|
661
|
+
if (!this._channel || this._channel.readyState !== 'open') {
|
|
662
|
+
throw new Error('datachannel is not open');
|
|
663
|
+
}
|
|
664
|
+
this._channel.send(payload);
|
|
665
|
+
}
|
|
666
|
+
on(event, handler) {
|
|
667
|
+
const listeners = this.#listeners.get(event) ?? new Set();
|
|
668
|
+
listeners.add(handler);
|
|
669
|
+
this.#listeners.set(event, listeners);
|
|
670
|
+
return this;
|
|
671
|
+
}
|
|
672
|
+
off(event, handler) {
|
|
673
|
+
this.#listeners.get(event)?.delete(handler);
|
|
674
|
+
return this;
|
|
675
|
+
}
|
|
676
|
+
#emit(event, ...args) {
|
|
677
|
+
const listeners = this.#listeners.get(event);
|
|
678
|
+
if (!listeners)
|
|
679
|
+
return;
|
|
680
|
+
for (const handler of listeners)
|
|
681
|
+
handler(...args);
|
|
682
|
+
}
|
|
683
|
+
signal(signalData) {
|
|
684
|
+
const rtc = this.#resolveRtc();
|
|
685
|
+
void (async () => {
|
|
686
|
+
try {
|
|
687
|
+
const signalCandidate = signalData;
|
|
688
|
+
if (typeof signalCandidate.candidate === 'string') {
|
|
689
|
+
await this.#addIceCandidate(rtc, signalCandidate);
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
const signalDescription = signalData;
|
|
693
|
+
if (typeof signalDescription.type === 'string' &&
|
|
694
|
+
typeof signalDescription.sdp === 'string') {
|
|
695
|
+
await this.#applyRemoteDescription(rtc, signalDescription);
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
throw new Error('invalid signal payload');
|
|
699
|
+
}
|
|
700
|
+
catch (error) {
|
|
701
|
+
this.#emit('error', error instanceof Error ? error : new Error(String(error)));
|
|
702
|
+
}
|
|
703
|
+
})();
|
|
704
|
+
}
|
|
705
|
+
destroy() {
|
|
706
|
+
if (this.#destroyed)
|
|
707
|
+
return;
|
|
708
|
+
this.#destroyed = true;
|
|
709
|
+
this.#connected = false;
|
|
710
|
+
if (this._channel) {
|
|
711
|
+
this._channel.onopen = null;
|
|
712
|
+
this._channel.onclose = null;
|
|
713
|
+
this._channel.onmessage = null;
|
|
714
|
+
this._channel.onerror = null;
|
|
715
|
+
try {
|
|
716
|
+
this._channel.close();
|
|
717
|
+
}
|
|
718
|
+
catch (e) { }
|
|
719
|
+
this._channel = null;
|
|
720
|
+
}
|
|
721
|
+
this.#pc.onicecandidate = null;
|
|
722
|
+
this.#pc.onconnectionstatechange = null;
|
|
723
|
+
this.#pc.ondatachannel = null;
|
|
724
|
+
try {
|
|
725
|
+
this.#pc.close();
|
|
726
|
+
}
|
|
727
|
+
catch (e) { }
|
|
728
|
+
this.#emit('close');
|
|
729
|
+
}
|
|
730
|
+
#getPubSub() {
|
|
731
|
+
const pubsubCandidate = globalThis
|
|
732
|
+
.pubsub;
|
|
733
|
+
if (pubsubCandidate &&
|
|
734
|
+
typeof pubsubCandidate.subscribe === 'function' &&
|
|
735
|
+
typeof pubsubCandidate.unsubscribe === 'function') {
|
|
736
|
+
return pubsubCandidate;
|
|
737
|
+
}
|
|
738
|
+
return null;
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* send to peer
|
|
742
|
+
* @param data Uint8Array
|
|
743
|
+
* @param id custom id to listen to
|
|
744
|
+
*/
|
|
745
|
+
send(data, id = this.#createMessageId()) {
|
|
746
|
+
this.#core.send(data, id);
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* send to peer & wait for response
|
|
750
|
+
* @param data Uint8Array
|
|
751
|
+
* @param id custom id to listen to
|
|
752
|
+
*/
|
|
753
|
+
request(data, id = this.#createMessageId()) {
|
|
754
|
+
return this.#core.request(data, id);
|
|
755
|
+
}
|
|
756
|
+
/**
|
|
757
|
+
* Get comprehensive network statistics from WebRTC
|
|
758
|
+
* @returns NetworkStats object with detailed metrics
|
|
759
|
+
*/
|
|
760
|
+
async getNetworkStats() {
|
|
761
|
+
try {
|
|
762
|
+
const pc = this._pc;
|
|
763
|
+
if (!pc)
|
|
764
|
+
return null;
|
|
765
|
+
const stats = await pc.getStats();
|
|
766
|
+
const result = {
|
|
767
|
+
latency: null,
|
|
768
|
+
jitter: null,
|
|
769
|
+
bytesReceived: 0,
|
|
770
|
+
bytesSent: 0,
|
|
771
|
+
packetsLost: 0,
|
|
772
|
+
fractionLost: null,
|
|
773
|
+
inboundBitrate: null,
|
|
774
|
+
outboundBitrate: null,
|
|
775
|
+
availableOutgoingBitrate: null,
|
|
776
|
+
timestamp: Date.now()
|
|
777
|
+
};
|
|
778
|
+
stats.forEach((report) => {
|
|
779
|
+
const typedReport = report;
|
|
780
|
+
// Latency from candidate pair
|
|
781
|
+
if (typedReport.type === 'candidate-pair' &&
|
|
782
|
+
typeof typedReport.currentRoundTripTime === 'number') {
|
|
783
|
+
result.latency = Math.round(typedReport.currentRoundTripTime * 1000);
|
|
784
|
+
}
|
|
785
|
+
// Inbound RTP stats
|
|
786
|
+
if (typedReport.type === 'inbound-rtp') {
|
|
787
|
+
result.bytesReceived += Number(typedReport.bytesReceived || 0);
|
|
788
|
+
result.packetsLost += Number(typedReport.packetsLost || 0);
|
|
789
|
+
if (typeof typedReport.jitter === 'number') {
|
|
790
|
+
result.jitter = Math.round(typedReport.jitter * 1000);
|
|
791
|
+
}
|
|
792
|
+
if (typeof typedReport.fractionLost === 'number') {
|
|
793
|
+
result.fractionLost = typedReport.fractionLost;
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
// Outbound RTP stats
|
|
797
|
+
if (typedReport.type === 'outbound-rtp') {
|
|
798
|
+
result.bytesSent += Number(typedReport.bytesSent || 0);
|
|
799
|
+
}
|
|
800
|
+
// Available bandwidth
|
|
801
|
+
if (typedReport.type === 'remote-candidate' &&
|
|
802
|
+
typeof typedReport.availableOutgoingBitrate === 'number') {
|
|
803
|
+
result.availableOutgoingBitrate = Math.round(typedReport.availableOutgoingBitrate);
|
|
804
|
+
}
|
|
805
|
+
});
|
|
806
|
+
return result;
|
|
807
|
+
}
|
|
808
|
+
catch (e) {
|
|
809
|
+
return null;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
toJSON() {
|
|
813
|
+
return {
|
|
814
|
+
peerId: this.peerId,
|
|
815
|
+
channelName: this.channelName,
|
|
816
|
+
version: this.version,
|
|
817
|
+
bw: this.bw
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const getWebTransportCtor = () => {
|
|
823
|
+
const candidate = globalThis;
|
|
824
|
+
if (!candidate.WebTransport) {
|
|
825
|
+
throw new Error('WebTransport is not available in this runtime');
|
|
826
|
+
}
|
|
827
|
+
return candidate.WebTransport;
|
|
828
|
+
};
|
|
829
|
+
class WebTransportPeer {
|
|
830
|
+
#session = null;
|
|
831
|
+
#reader = null;
|
|
832
|
+
#writer = null;
|
|
833
|
+
#listeners = new Map();
|
|
834
|
+
#destroyed = false;
|
|
835
|
+
#connected = false;
|
|
836
|
+
#core;
|
|
837
|
+
initiator = true;
|
|
838
|
+
peerId;
|
|
839
|
+
channelName;
|
|
840
|
+
version;
|
|
841
|
+
compressionThreshold = 0.98;
|
|
842
|
+
bw = { up: 0, down: 0 };
|
|
843
|
+
get connected() {
|
|
844
|
+
return this.#connected;
|
|
845
|
+
}
|
|
846
|
+
constructor(options) {
|
|
847
|
+
this.peerId = options.to;
|
|
848
|
+
this.channelName = `webtransport:${options.from}:${options.to}`;
|
|
849
|
+
this.version = String(options.version);
|
|
850
|
+
if (options.compressionThreshold !== undefined) {
|
|
851
|
+
this.compressionThreshold = options.compressionThreshold;
|
|
852
|
+
}
|
|
853
|
+
this.#core = new PeerCore({
|
|
854
|
+
kind: 'webtransport-bidi',
|
|
855
|
+
isConnected: () => this.connected,
|
|
856
|
+
sendRaw: (payload) => {
|
|
857
|
+
void this.#sendRaw(payload);
|
|
858
|
+
}
|
|
859
|
+
}, {
|
|
860
|
+
maxMessageSize: MAX_MESSAGE_SIZE,
|
|
861
|
+
compressionThreshold: this.compressionThreshold,
|
|
862
|
+
getPubSub: this.#getPubSub,
|
|
863
|
+
onPayloadSend: (payloadBytes) => {
|
|
864
|
+
this.bw.up += payloadBytes;
|
|
865
|
+
}
|
|
866
|
+
});
|
|
867
|
+
queueMicrotask(() => {
|
|
868
|
+
void this.#open(options.url);
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
#createMessageId() {
|
|
872
|
+
const randomUUID = globalThis.crypto?.randomUUID;
|
|
873
|
+
if (typeof randomUUID === 'function') {
|
|
874
|
+
return randomUUID.call(globalThis.crypto);
|
|
875
|
+
}
|
|
876
|
+
return `msg-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
877
|
+
}
|
|
878
|
+
#getPubSub() {
|
|
879
|
+
const pubsubCandidate = globalThis
|
|
880
|
+
.pubsub;
|
|
881
|
+
if (pubsubCandidate &&
|
|
882
|
+
typeof pubsubCandidate.subscribe === 'function' &&
|
|
883
|
+
typeof pubsubCandidate.unsubscribe === 'function') {
|
|
884
|
+
return pubsubCandidate;
|
|
885
|
+
}
|
|
886
|
+
return null;
|
|
887
|
+
}
|
|
888
|
+
#emit(event, ...args) {
|
|
889
|
+
const listeners = this.#listeners.get(event);
|
|
890
|
+
if (!listeners)
|
|
891
|
+
return;
|
|
892
|
+
for (const handler of listeners)
|
|
893
|
+
handler(...args);
|
|
894
|
+
}
|
|
895
|
+
async #open(url) {
|
|
896
|
+
try {
|
|
897
|
+
const WebTransportCtor = getWebTransportCtor();
|
|
898
|
+
const session = new WebTransportCtor(url);
|
|
899
|
+
this.#session = session;
|
|
900
|
+
await session.ready;
|
|
901
|
+
const stream = (await session.createBidirectionalStream());
|
|
902
|
+
this.#reader = stream.readable.getReader();
|
|
903
|
+
this.#writer = stream.writable.getWriter();
|
|
904
|
+
this.#connected = true;
|
|
905
|
+
this.#emit('connect');
|
|
906
|
+
void this.#readLoop();
|
|
907
|
+
void session.closed.catch((error) => {
|
|
908
|
+
if (this.#destroyed)
|
|
909
|
+
return;
|
|
910
|
+
this.#emit('error', error instanceof Error ? error : new Error(String(error)));
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
catch (error) {
|
|
914
|
+
this.#emit('error', error instanceof Error ? error : new Error(String(error)));
|
|
915
|
+
this.destroy();
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
async #readLoop() {
|
|
919
|
+
const reader = this.#reader;
|
|
920
|
+
if (!reader)
|
|
921
|
+
return;
|
|
922
|
+
try {
|
|
923
|
+
while (!this.#destroyed) {
|
|
924
|
+
// eslint-disable-next-line no-await-in-loop
|
|
925
|
+
const { done, value } = await reader.read();
|
|
926
|
+
if (done)
|
|
927
|
+
break;
|
|
928
|
+
if (!value)
|
|
929
|
+
continue;
|
|
930
|
+
this.bw.down += value.length;
|
|
931
|
+
this.#emit('data', value);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
catch (error) {
|
|
935
|
+
if (!this.#destroyed) {
|
|
936
|
+
this.#emit('error', error instanceof Error ? error : new Error(String(error)));
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
if (!this.#destroyed)
|
|
940
|
+
this.destroy();
|
|
941
|
+
}
|
|
942
|
+
async #sendRaw(payload) {
|
|
943
|
+
if (!this.#writer)
|
|
944
|
+
throw new Error('WebTransport writer is not ready');
|
|
945
|
+
await this.#writer.write(payload);
|
|
946
|
+
}
|
|
947
|
+
on(event, handler) {
|
|
948
|
+
const listeners = this.#listeners.get(event) ?? new Set();
|
|
949
|
+
listeners.add(handler);
|
|
950
|
+
this.#listeners.set(event, listeners);
|
|
951
|
+
return this;
|
|
952
|
+
}
|
|
953
|
+
off(event, handler) {
|
|
954
|
+
this.#listeners.get(event)?.delete(handler);
|
|
955
|
+
return this;
|
|
956
|
+
}
|
|
957
|
+
signal(_signalData) {
|
|
958
|
+
// WebTransport does not use SDP/ICE signaling in this peer implementation.
|
|
959
|
+
}
|
|
960
|
+
send(data, id = this.#createMessageId()) {
|
|
961
|
+
this.#core.send(data, id);
|
|
962
|
+
}
|
|
963
|
+
request(data, id = this.#createMessageId()) {
|
|
964
|
+
return this.#core.request(data, id);
|
|
965
|
+
}
|
|
966
|
+
async getNetworkStats() {
|
|
967
|
+
return null;
|
|
968
|
+
}
|
|
969
|
+
destroy() {
|
|
970
|
+
if (this.#destroyed)
|
|
971
|
+
return;
|
|
972
|
+
this.#destroyed = true;
|
|
973
|
+
this.#connected = false;
|
|
974
|
+
try {
|
|
975
|
+
void this.#reader?.cancel?.();
|
|
976
|
+
}
|
|
977
|
+
catch (e) { }
|
|
978
|
+
this.#reader?.releaseLock?.();
|
|
979
|
+
this.#reader = null;
|
|
980
|
+
try {
|
|
981
|
+
void this.#writer?.close?.();
|
|
982
|
+
}
|
|
983
|
+
catch (e) { }
|
|
984
|
+
this.#writer?.releaseLock?.();
|
|
985
|
+
this.#writer = null;
|
|
986
|
+
try {
|
|
987
|
+
this.#session?.close?.();
|
|
988
|
+
}
|
|
989
|
+
catch (e) { }
|
|
990
|
+
this.#session = null;
|
|
991
|
+
this.#emit('close');
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
const getPubSub = () => {
|
|
996
|
+
const candidate = globalThis.pubsub;
|
|
997
|
+
if (candidate &&
|
|
998
|
+
typeof candidate.subscribe === 'function' &&
|
|
999
|
+
typeof candidate.unsubscribe === 'function') {
|
|
1000
|
+
return candidate;
|
|
1001
|
+
}
|
|
1002
|
+
return null;
|
|
1003
|
+
};
|
|
1004
|
+
class CircuitPeer {
|
|
1005
|
+
#listeners = new Map();
|
|
1006
|
+
#destroyed = false;
|
|
1007
|
+
#connected = false;
|
|
1008
|
+
#sendViaCircuit;
|
|
1009
|
+
initiator = true;
|
|
1010
|
+
peerId;
|
|
1011
|
+
channelName;
|
|
1012
|
+
version;
|
|
1013
|
+
bw = { up: 0, down: 0 };
|
|
1014
|
+
get connected() {
|
|
1015
|
+
return this.#connected;
|
|
1016
|
+
}
|
|
1017
|
+
constructor(options) {
|
|
1018
|
+
this.peerId = options.to;
|
|
1019
|
+
this.channelName = `circuit:${options.from}:${options.to}`;
|
|
1020
|
+
this.version = String(options.version);
|
|
1021
|
+
this.#sendViaCircuit = options.sendViaCircuit;
|
|
1022
|
+
setTimeout(() => {
|
|
1023
|
+
if (this.#destroyed)
|
|
1024
|
+
return;
|
|
1025
|
+
this.#connected = true;
|
|
1026
|
+
this.#emit('connect');
|
|
1027
|
+
}, 0);
|
|
1028
|
+
}
|
|
1029
|
+
#createMessageId() {
|
|
1030
|
+
const randomUUID = globalThis.crypto?.randomUUID;
|
|
1031
|
+
if (typeof randomUUID === 'function') {
|
|
1032
|
+
return randomUUID.call(globalThis.crypto);
|
|
1033
|
+
}
|
|
1034
|
+
return `msg-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
1035
|
+
}
|
|
1036
|
+
#emit(event, ...args) {
|
|
1037
|
+
const listeners = this.#listeners.get(event);
|
|
1038
|
+
if (!listeners)
|
|
1039
|
+
return;
|
|
1040
|
+
for (const handler of listeners)
|
|
1041
|
+
handler(...args);
|
|
1042
|
+
}
|
|
1043
|
+
on(event, handler) {
|
|
1044
|
+
const listeners = this.#listeners.get(event) ?? new Set();
|
|
1045
|
+
listeners.add(handler);
|
|
1046
|
+
this.#listeners.set(event, listeners);
|
|
1047
|
+
return this;
|
|
1048
|
+
}
|
|
1049
|
+
off(event, handler) {
|
|
1050
|
+
this.#listeners.get(event)?.delete(handler);
|
|
1051
|
+
return this;
|
|
1052
|
+
}
|
|
1053
|
+
signal(_signalData) {
|
|
1054
|
+
// Circuit transport does not use SDP/ICE signaling.
|
|
1055
|
+
}
|
|
1056
|
+
send(data, id = this.#createMessageId()) {
|
|
1057
|
+
if (this.#destroyed) {
|
|
1058
|
+
this.#emit('error', new Error('circuit peer is destroyed'));
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
this.bw.up += data.length;
|
|
1062
|
+
void this.#sendViaCircuit({ id, data: Array.from(data) }).catch((error) => {
|
|
1063
|
+
this.#emit('error', error instanceof Error ? error : new Error(String(error)));
|
|
1064
|
+
});
|
|
1065
|
+
}
|
|
1066
|
+
request(data, id = this.#createMessageId()) {
|
|
1067
|
+
const pubsub = getPubSub();
|
|
1068
|
+
if (!pubsub) {
|
|
1069
|
+
return Promise.reject(new Error('globalThis.pubsub is not available'));
|
|
1070
|
+
}
|
|
1071
|
+
return new Promise((resolve, reject) => {
|
|
1072
|
+
let timeout;
|
|
1073
|
+
let settled = false;
|
|
1074
|
+
const finish = (fn) => {
|
|
1075
|
+
if (settled)
|
|
1076
|
+
return;
|
|
1077
|
+
settled = true;
|
|
1078
|
+
clearTimeout(timeout);
|
|
1079
|
+
try {
|
|
1080
|
+
pubsub.unsubscribe(id, onResponse);
|
|
1081
|
+
}
|
|
1082
|
+
catch (e) { }
|
|
1083
|
+
fn();
|
|
1084
|
+
};
|
|
1085
|
+
const onResponse = ({ data: response }) => {
|
|
1086
|
+
finish(() => resolve(response));
|
|
1087
|
+
};
|
|
1088
|
+
timeout = setTimeout(() => {
|
|
1089
|
+
finish(() => reject(new Error(`request for ${id} timed out`)));
|
|
1090
|
+
}, 30_000);
|
|
1091
|
+
pubsub.subscribe(id, onResponse);
|
|
1092
|
+
this.send(data, id);
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
async getNetworkStats() {
|
|
1096
|
+
return null;
|
|
1097
|
+
}
|
|
1098
|
+
destroy() {
|
|
1099
|
+
if (this.#destroyed)
|
|
1100
|
+
return;
|
|
1101
|
+
this.#destroyed = true;
|
|
1102
|
+
this.#connected = false;
|
|
1103
|
+
this.#emit('close');
|
|
1104
|
+
}
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
// Simple CRC32 implementation
|
|
1108
|
+
const crc32 = (data) => {
|
|
1109
|
+
let crc = 0xffffffff;
|
|
1110
|
+
for (let i = 0; i < data.length; i++) {
|
|
1111
|
+
crc ^= data[i];
|
|
1112
|
+
for (let j = 0; j < 8; j++) {
|
|
1113
|
+
crc = (crc >>> 1) ^ (crc & 1 ? 0xedb88320 : 0);
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
1117
|
+
};
|
|
1118
|
+
const debug = createDebugger('@netpeer/swarm/client');
|
|
1119
|
+
const isWrtcImplementation = (candidate) => {
|
|
1120
|
+
if (!candidate || typeof candidate !== 'object')
|
|
1121
|
+
return false;
|
|
1122
|
+
const wrtcCandidate = candidate;
|
|
1123
|
+
return (typeof wrtcCandidate.RTCPeerConnection === 'function' &&
|
|
1124
|
+
typeof wrtcCandidate.RTCSessionDescription === 'function' &&
|
|
1125
|
+
typeof wrtcCandidate.RTCIceCandidate === 'function');
|
|
1126
|
+
};
|
|
1127
|
+
class Client {
|
|
1128
|
+
#peerId;
|
|
1129
|
+
#connections = {};
|
|
1130
|
+
#stars = {};
|
|
1131
|
+
#starListeners = {};
|
|
1132
|
+
#reinitLock = null;
|
|
1133
|
+
#connectEvent = 'peer:connected';
|
|
1134
|
+
#supportedTransports = new Set(['webrtc']);
|
|
1135
|
+
#preferredTransportKind = 'webrtc';
|
|
1136
|
+
#fallbackTransportOrder = [];
|
|
1137
|
+
#enableCircuitFallback = true;
|
|
1138
|
+
#transportConnectTimeoutMs = 10_000;
|
|
1139
|
+
#peerTransportAttempts = new Map();
|
|
1140
|
+
#peerTransportKinds = new Map();
|
|
1141
|
+
#fallbackCircuitMethod = 'swarm:fallback:send';
|
|
1142
|
+
#testHooks;
|
|
1143
|
+
#webTransportUrlTemplate;
|
|
1144
|
+
#circuitHandlers = new Map();
|
|
1145
|
+
#pendingCircuitRequests = new Map();
|
|
1146
|
+
#circuitRequestTimeoutMs = 30_000;
|
|
1147
|
+
#retryOptions = { retries: 5, factor: 2, minTimeout: 1000, maxTimeout: 30000 };
|
|
1148
|
+
id;
|
|
1149
|
+
networkVersion;
|
|
1150
|
+
starsConfig;
|
|
1151
|
+
socketClient;
|
|
1152
|
+
messageSize = 262144;
|
|
1153
|
+
version;
|
|
1154
|
+
#messagesToHandle = {};
|
|
1155
|
+
get peerId() {
|
|
1156
|
+
return this.#peerId;
|
|
1157
|
+
}
|
|
1158
|
+
get connections() {
|
|
1159
|
+
return { ...this.#connections };
|
|
1160
|
+
}
|
|
1161
|
+
get peers() {
|
|
1162
|
+
return Object.entries(this.#connections);
|
|
1163
|
+
}
|
|
1164
|
+
getPeer(peerId) {
|
|
1165
|
+
return this.#connections[peerId];
|
|
1166
|
+
}
|
|
1167
|
+
constructor(options) {
|
|
1168
|
+
const { peerId, networkVersion, version, connectEvent, stars } = {
|
|
1169
|
+
...defaultOptions,
|
|
1170
|
+
...options
|
|
1171
|
+
};
|
|
1172
|
+
this.#peerId = peerId;
|
|
1173
|
+
this.networkVersion = networkVersion;
|
|
1174
|
+
this.version = version;
|
|
1175
|
+
this.#testHooks = options.testHooks;
|
|
1176
|
+
this.#connectEvent = connectEvent;
|
|
1177
|
+
this.starsConfig = stars;
|
|
1178
|
+
const configuredKinds = options.transport?.kinds
|
|
1179
|
+
? options.transport.kinds
|
|
1180
|
+
: options.transport?.kind
|
|
1181
|
+
? [options.transport.kind]
|
|
1182
|
+
: ['webrtc', 'webtransport'];
|
|
1183
|
+
this.#supportedTransports = new Set(this.#normalizeTransportKinds(configuredKinds));
|
|
1184
|
+
if (!this.#supportedTransports.size)
|
|
1185
|
+
this.#supportedTransports.add('webrtc');
|
|
1186
|
+
const configuredPreferred = options.transport?.preferredKind || options.transport?.kind || 'webrtc';
|
|
1187
|
+
this.#preferredTransportKind = this.#supportedTransports.has(configuredPreferred)
|
|
1188
|
+
? configuredPreferred
|
|
1189
|
+
: this.#supportedTransports.has('webrtc')
|
|
1190
|
+
? 'webrtc'
|
|
1191
|
+
: 'webtransport';
|
|
1192
|
+
this.#webTransportUrlTemplate = options.transport?.webtransport?.urlTemplate;
|
|
1193
|
+
this.#enableCircuitFallback = options.transport?.fallback?.enabled ?? true;
|
|
1194
|
+
if (typeof options.transport?.fallback?.connectTimeoutMs === 'number' &&
|
|
1195
|
+
options.transport.fallback.connectTimeoutMs > 0) {
|
|
1196
|
+
this.#transportConnectTimeoutMs =
|
|
1197
|
+
options.transport.fallback.connectTimeoutMs;
|
|
1198
|
+
}
|
|
1199
|
+
this.#fallbackTransportOrder = this.#normalizeFallbackOrder(options.transport?.fallback?.order);
|
|
1200
|
+
if (options?.retry)
|
|
1201
|
+
this.#retryOptions = { ...this.#retryOptions, ...options.retry };
|
|
1202
|
+
this.#circuitHandlers.set(this.#fallbackCircuitMethod, (payload, context) => this.#handleFallbackCircuitPayload(payload, context));
|
|
1203
|
+
if (!this.#testHooks?.skipInit) {
|
|
1204
|
+
this._init();
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
/**
|
|
1208
|
+
* Safely reinitialize the client (used after system resume/sleep).
|
|
1209
|
+
* It closes existing connections and reconnects to configured stars.
|
|
1210
|
+
*/
|
|
1211
|
+
async reinit() {
|
|
1212
|
+
// avoid concurrent reinit runs
|
|
1213
|
+
if (this.#reinitLock)
|
|
1214
|
+
return this.#reinitLock;
|
|
1215
|
+
this.#reinitLock = (async () => {
|
|
1216
|
+
debug('reinit: start');
|
|
1217
|
+
try {
|
|
1218
|
+
await this.close();
|
|
1219
|
+
this.#stars = {};
|
|
1220
|
+
this.#connections = {};
|
|
1221
|
+
for (const star of this.starsConfig) {
|
|
1222
|
+
try {
|
|
1223
|
+
await this.setupStar(star);
|
|
1224
|
+
}
|
|
1225
|
+
catch (e) {
|
|
1226
|
+
// If last star fails and none connected, surface error
|
|
1227
|
+
if (Object.keys(this.#stars).length === 0)
|
|
1228
|
+
throw new Error(`No star available to connect`);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
finally {
|
|
1233
|
+
debug('reinit: done');
|
|
1234
|
+
this.#reinitLock = null;
|
|
1235
|
+
}
|
|
1236
|
+
})();
|
|
1237
|
+
return this.#reinitLock;
|
|
1238
|
+
}
|
|
1239
|
+
async setupStar(star) {
|
|
1240
|
+
const { retries, factor, minTimeout, maxTimeout } = this.#retryOptions;
|
|
1241
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
1242
|
+
let attempt = 0;
|
|
1243
|
+
let lastErr;
|
|
1244
|
+
while (attempt <= retries) {
|
|
1245
|
+
try {
|
|
1246
|
+
const client = new SocketRequestClient(star, this.networkVersion);
|
|
1247
|
+
this.#stars[star] = await client.init();
|
|
1248
|
+
this.setupStarListeners(this.#stars[star], star);
|
|
1249
|
+
this.#stars[star].send({
|
|
1250
|
+
url: 'join',
|
|
1251
|
+
params: {
|
|
1252
|
+
version: this.version,
|
|
1253
|
+
peerId: this.peerId,
|
|
1254
|
+
transport: {
|
|
1255
|
+
kind: this.#preferredTransportKind,
|
|
1256
|
+
kinds: [...this.#supportedTransports]
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
});
|
|
1260
|
+
globalThis.pubsub.publishVerbose('star:connected', star);
|
|
1261
|
+
debug(`setupStar ${star} succeeded`);
|
|
1262
|
+
return this.#stars[star];
|
|
1263
|
+
}
|
|
1264
|
+
catch (e) {
|
|
1265
|
+
lastErr = e;
|
|
1266
|
+
attempt += 1;
|
|
1267
|
+
if (attempt > retries)
|
|
1268
|
+
break;
|
|
1269
|
+
const delay = Math.min(maxTimeout, Math.round(minTimeout * Math.pow(factor, attempt - 1)));
|
|
1270
|
+
debug(`setupStar ${star} failed, retrying in ${delay}ms (attempt ${attempt})`);
|
|
1271
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1272
|
+
await sleep(delay);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
throw lastErr;
|
|
1276
|
+
}
|
|
1277
|
+
async _init() {
|
|
1278
|
+
if (!globalThis.RTCPeerConnection &&
|
|
1279
|
+
!globalThis.wrtc) {
|
|
1280
|
+
const { backend, implementation } = await this.#loadNodeWebrtcImplementation();
|
|
1281
|
+
globalThis.wrtc = implementation;
|
|
1282
|
+
globalThis.__swarmWrtcImpl = backend;
|
|
1283
|
+
}
|
|
1284
|
+
for (const star of this.starsConfig) {
|
|
1285
|
+
try {
|
|
1286
|
+
await this.setupStar(star);
|
|
1287
|
+
}
|
|
1288
|
+
catch (e) {
|
|
1289
|
+
if (this.starsConfig.indexOf(star) === this.starsConfig.length - 1 &&
|
|
1290
|
+
!this.socketClient)
|
|
1291
|
+
throw new Error(`No star available to connect`);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
if (globalThis.process?.versions?.node) {
|
|
1295
|
+
process.on('SIGINT', async () => {
|
|
1296
|
+
process.stdin.resume();
|
|
1297
|
+
await this.close();
|
|
1298
|
+
process.exit();
|
|
1299
|
+
});
|
|
1300
|
+
}
|
|
1301
|
+
else {
|
|
1302
|
+
globalThis.addEventListener('beforeunload', this.close.bind(this));
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
async #loadNodeWebrtcImplementation() {
|
|
1306
|
+
const koushWrtcModule = await import('./browser-FVp_QbaL.js').then(function (n) { return n.b; });
|
|
1307
|
+
const koushWrtc = koushWrtcModule.default;
|
|
1308
|
+
if (!isWrtcImplementation(koushWrtc)) {
|
|
1309
|
+
throw new Error('@koush/wrtc does not match required wrtc contract');
|
|
1310
|
+
}
|
|
1311
|
+
debug('using @koush/wrtc as wrtc implementation');
|
|
1312
|
+
return { backend: 'koush', implementation: koushWrtc };
|
|
1313
|
+
}
|
|
1314
|
+
setupStarListeners(starConnection, starId) {
|
|
1315
|
+
// create stable references to handlers so we can unsubscribe later
|
|
1316
|
+
const onPeerJoined = (id) => this.#peerJoined(id, starConnection);
|
|
1317
|
+
const onPeerLeft = (id) => this.#peerLeft(id, starConnection);
|
|
1318
|
+
const onStarJoined = this.#starJoined;
|
|
1319
|
+
const onStarLeft = this.#starLeft;
|
|
1320
|
+
const onSignal = (message) => this.#inComingSignal(message, starConnection);
|
|
1321
|
+
const onCircuit = (message) => this.#inComingCircuit(message, starConnection);
|
|
1322
|
+
const onServerData = (message) => {
|
|
1323
|
+
globalThis.pubsub.publish('server:data', message);
|
|
1324
|
+
};
|
|
1325
|
+
starConnection.pubsub.subscribe('peer:joined', onPeerJoined);
|
|
1326
|
+
starConnection.pubsub.subscribe('peer:left', onPeerLeft);
|
|
1327
|
+
starConnection.pubsub.subscribe('star:joined', onStarJoined);
|
|
1328
|
+
starConnection.pubsub.subscribe('star:left', onStarLeft);
|
|
1329
|
+
starConnection.pubsub.subscribe('signal', onSignal);
|
|
1330
|
+
starConnection.pubsub.subscribe('circuit', onCircuit);
|
|
1331
|
+
starConnection.pubsub.subscribe('server:data', onServerData);
|
|
1332
|
+
this.#starListeners[starId] = [
|
|
1333
|
+
{ topic: 'peer:joined', handler: onPeerJoined },
|
|
1334
|
+
{ topic: 'peer:left', handler: onPeerLeft },
|
|
1335
|
+
{ topic: 'star:joined', handler: onStarJoined },
|
|
1336
|
+
{ topic: 'star:left', handler: onStarLeft },
|
|
1337
|
+
{ topic: 'signal', handler: onSignal },
|
|
1338
|
+
{ topic: 'circuit', handler: onCircuit },
|
|
1339
|
+
{ topic: 'server:data', handler: onServerData }
|
|
1340
|
+
];
|
|
1341
|
+
}
|
|
1342
|
+
#starJoined = (id) => {
|
|
1343
|
+
if (this.#stars[id]) {
|
|
1344
|
+
this.#stars[id].close(0);
|
|
1345
|
+
delete this.#stars[id];
|
|
1346
|
+
}
|
|
1347
|
+
console.log(`star ${id} joined`);
|
|
1348
|
+
};
|
|
1349
|
+
#starLeft = async (id) => {
|
|
1350
|
+
if (this.#stars[id]) {
|
|
1351
|
+
this.#stars[id].close(0);
|
|
1352
|
+
delete this.#stars[id];
|
|
1353
|
+
}
|
|
1354
|
+
// if we lost all stars, try to reconnect to configured stars with backoff
|
|
1355
|
+
if (Object.keys(this.#stars).length === 0) {
|
|
1356
|
+
for (const star of this.starsConfig) {
|
|
1357
|
+
try {
|
|
1358
|
+
await this.setupStar(star);
|
|
1359
|
+
// stop at first success
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
catch (e) {
|
|
1363
|
+
debug(`reconnect star ${star} failed: ${e.message || e}`);
|
|
1364
|
+
if (this.starsConfig.indexOf(star) === this.starsConfig.length - 1)
|
|
1365
|
+
throw new Error(`No star available to connect`);
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
debug(`star ${id} left`);
|
|
1370
|
+
};
|
|
1371
|
+
#peerLeft = (peer, star) => {
|
|
1372
|
+
const id = peer.peerId || peer;
|
|
1373
|
+
if (this.#connections[id]) {
|
|
1374
|
+
this.#connections[id].destroy();
|
|
1375
|
+
delete this.#connections[id];
|
|
1376
|
+
}
|
|
1377
|
+
debug(`peer ${id} left`);
|
|
1378
|
+
};
|
|
1379
|
+
#normalizeTransportKinds(kinds) {
|
|
1380
|
+
if (!Array.isArray(kinds))
|
|
1381
|
+
return [];
|
|
1382
|
+
const normalized = new Set();
|
|
1383
|
+
for (const kind of kinds) {
|
|
1384
|
+
if (kind === 'webrtc' || kind === 'webtransport')
|
|
1385
|
+
normalized.add(kind);
|
|
1386
|
+
}
|
|
1387
|
+
return [...normalized];
|
|
1388
|
+
}
|
|
1389
|
+
#normalizeFallbackOrder(kinds) {
|
|
1390
|
+
if (!Array.isArray(kinds))
|
|
1391
|
+
return [];
|
|
1392
|
+
const normalized = new Set();
|
|
1393
|
+
for (const kind of kinds) {
|
|
1394
|
+
if (kind === 'webrtc' || kind === 'webtransport' || kind === 'circuit') {
|
|
1395
|
+
normalized.add(kind);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
return [...normalized];
|
|
1399
|
+
}
|
|
1400
|
+
#supportsTransport(kind) {
|
|
1401
|
+
return this.#supportedTransports.has(kind);
|
|
1402
|
+
}
|
|
1403
|
+
#getRemoteTransportKinds(transport) {
|
|
1404
|
+
const fromKinds = this.#normalizeTransportKinds(transport?.kinds);
|
|
1405
|
+
if (fromKinds.length)
|
|
1406
|
+
return fromKinds;
|
|
1407
|
+
if (transport?.kind === 'webrtc' || transport?.kind === 'webtransport') {
|
|
1408
|
+
return [transport.kind];
|
|
1409
|
+
}
|
|
1410
|
+
return ['webrtc'];
|
|
1411
|
+
}
|
|
1412
|
+
#selectTransportForPeer(peerId, transport) {
|
|
1413
|
+
const remoteKinds = this.#getRemoteTransportKinds(transport);
|
|
1414
|
+
const commonKinds = remoteKinds.filter((kind) => this.#supportsTransport(kind));
|
|
1415
|
+
if (!commonKinds.length) {
|
|
1416
|
+
debug(`peer ${peerId} has no compatible transport with local capabilities`);
|
|
1417
|
+
return null;
|
|
1418
|
+
}
|
|
1419
|
+
if (transport?.kind && commonKinds.includes(transport.kind)) {
|
|
1420
|
+
return transport.kind;
|
|
1421
|
+
}
|
|
1422
|
+
if (commonKinds.includes(this.#preferredTransportKind)) {
|
|
1423
|
+
return this.#preferredTransportKind;
|
|
1424
|
+
}
|
|
1425
|
+
if (commonKinds.includes('webrtc'))
|
|
1426
|
+
return 'webrtc';
|
|
1427
|
+
return 'webtransport';
|
|
1428
|
+
}
|
|
1429
|
+
#buildTransportOrder(peerId, transport) {
|
|
1430
|
+
const remoteKinds = this.#getRemoteTransportKinds(transport);
|
|
1431
|
+
const commonKinds = remoteKinds.filter((kind) => this.#supportsTransport(kind));
|
|
1432
|
+
if (!commonKinds.length) {
|
|
1433
|
+
if (this.#enableCircuitFallback) {
|
|
1434
|
+
debug(`peer ${peerId} has no direct transport overlap, using circuit`);
|
|
1435
|
+
return ['circuit'];
|
|
1436
|
+
}
|
|
1437
|
+
return [];
|
|
1438
|
+
}
|
|
1439
|
+
const order = [];
|
|
1440
|
+
if (transport?.kind && commonKinds.includes(transport.kind)) {
|
|
1441
|
+
order.push(transport.kind);
|
|
1442
|
+
}
|
|
1443
|
+
if (commonKinds.includes(this.#preferredTransportKind) &&
|
|
1444
|
+
!order.includes(this.#preferredTransportKind)) {
|
|
1445
|
+
order.push(this.#preferredTransportKind);
|
|
1446
|
+
}
|
|
1447
|
+
for (const kind of this.#fallbackTransportOrder) {
|
|
1448
|
+
if (kind !== 'circuit' &&
|
|
1449
|
+
commonKinds.includes(kind) &&
|
|
1450
|
+
!order.includes(kind)) {
|
|
1451
|
+
order.push(kind);
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
if (commonKinds.includes('webrtc') && !order.includes('webrtc')) {
|
|
1455
|
+
order.push('webrtc');
|
|
1456
|
+
}
|
|
1457
|
+
if (commonKinds.includes('webtransport') &&
|
|
1458
|
+
!order.includes('webtransport')) {
|
|
1459
|
+
order.push('webtransport');
|
|
1460
|
+
}
|
|
1461
|
+
if (this.#enableCircuitFallback && !order.includes('circuit')) {
|
|
1462
|
+
order.push('circuit');
|
|
1463
|
+
}
|
|
1464
|
+
debug(`peer ${peerId} transport order: ${order.join(' -> ')}`);
|
|
1465
|
+
return order;
|
|
1466
|
+
}
|
|
1467
|
+
#clearTransportAttempt(peerId) {
|
|
1468
|
+
const attempt = this.#peerTransportAttempts.get(peerId);
|
|
1469
|
+
if (attempt?.timeout)
|
|
1470
|
+
clearTimeout(attempt.timeout);
|
|
1471
|
+
this.#peerTransportAttempts.delete(peerId);
|
|
1472
|
+
}
|
|
1473
|
+
#emitTransportEvent(event) {
|
|
1474
|
+
this.#testHooks?.onTransportEvent?.(event);
|
|
1475
|
+
}
|
|
1476
|
+
#advanceTransportAttempt(peerId) {
|
|
1477
|
+
const attempt = this.#peerTransportAttempts.get(peerId);
|
|
1478
|
+
if (!attempt)
|
|
1479
|
+
return;
|
|
1480
|
+
const previousIndex = attempt.index;
|
|
1481
|
+
const previousTransport = attempt.order[previousIndex];
|
|
1482
|
+
if (attempt.timeout)
|
|
1483
|
+
clearTimeout(attempt.timeout);
|
|
1484
|
+
this.#emitTransportEvent({
|
|
1485
|
+
type: 'attempt-advanced',
|
|
1486
|
+
peerId,
|
|
1487
|
+
transport: previousTransport,
|
|
1488
|
+
attemptIndex: previousIndex,
|
|
1489
|
+
order: [...attempt.order]
|
|
1490
|
+
});
|
|
1491
|
+
attempt.index += 1;
|
|
1492
|
+
this.#attemptPeerTransport(peerId);
|
|
1493
|
+
}
|
|
1494
|
+
#startPeerTransportAttempt(peerId, star, version, initiator, transport) {
|
|
1495
|
+
if (this.#connections[peerId])
|
|
1496
|
+
return;
|
|
1497
|
+
if (this.#peerTransportAttempts.has(peerId))
|
|
1498
|
+
return;
|
|
1499
|
+
const order = this.#buildTransportOrder(peerId, transport);
|
|
1500
|
+
if (!order.length) {
|
|
1501
|
+
debug(`peer ${peerId} has no available transport to attempt`);
|
|
1502
|
+
return;
|
|
1503
|
+
}
|
|
1504
|
+
this.#peerTransportAttempts.set(peerId, {
|
|
1505
|
+
order,
|
|
1506
|
+
index: 0,
|
|
1507
|
+
star,
|
|
1508
|
+
version,
|
|
1509
|
+
initiator
|
|
1510
|
+
});
|
|
1511
|
+
this.#attemptPeerTransport(peerId);
|
|
1512
|
+
}
|
|
1513
|
+
#attemptPeerTransport(peerId) {
|
|
1514
|
+
const attempt = this.#peerTransportAttempts.get(peerId);
|
|
1515
|
+
if (!attempt)
|
|
1516
|
+
return;
|
|
1517
|
+
if (attempt.index >= attempt.order.length) {
|
|
1518
|
+
const exhaustedTransport = attempt.order[attempt.order.length - 1] ?? 'circuit';
|
|
1519
|
+
this.#emitTransportEvent({
|
|
1520
|
+
type: 'attempts-exhausted',
|
|
1521
|
+
peerId,
|
|
1522
|
+
transport: exhaustedTransport,
|
|
1523
|
+
attemptIndex: attempt.index,
|
|
1524
|
+
order: [...attempt.order]
|
|
1525
|
+
});
|
|
1526
|
+
debug(`peer ${peerId} transport attempts exhausted`);
|
|
1527
|
+
this.#clearTransportAttempt(peerId);
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
if (this.#connections[peerId])
|
|
1531
|
+
return;
|
|
1532
|
+
const transportKind = attempt.order[attempt.index];
|
|
1533
|
+
this.#emitTransportEvent({
|
|
1534
|
+
type: 'attempt-started',
|
|
1535
|
+
peerId,
|
|
1536
|
+
transport: transportKind,
|
|
1537
|
+
attemptIndex: attempt.index,
|
|
1538
|
+
order: [...attempt.order]
|
|
1539
|
+
});
|
|
1540
|
+
let peer = null;
|
|
1541
|
+
if (transportKind === 'webrtc') {
|
|
1542
|
+
peer = this.#createRTCPeerConnection(peerId, attempt.star, attempt.version, attempt.initiator);
|
|
1543
|
+
}
|
|
1544
|
+
else if (transportKind === 'webtransport') {
|
|
1545
|
+
const url = this.#resolveWebTransportUrl(peerId);
|
|
1546
|
+
if (!url) {
|
|
1547
|
+
this.#advanceTransportAttempt(peerId);
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
peer = this.#createWebTransportConnection(peerId, url, attempt.version);
|
|
1551
|
+
}
|
|
1552
|
+
else {
|
|
1553
|
+
peer = this.#createCircuitConnection(peerId, attempt.version);
|
|
1554
|
+
}
|
|
1555
|
+
if (!peer) {
|
|
1556
|
+
this.#advanceTransportAttempt(peerId);
|
|
1557
|
+
return;
|
|
1558
|
+
}
|
|
1559
|
+
attempt.timeout = setTimeout(() => {
|
|
1560
|
+
const current = this.#connections[peerId];
|
|
1561
|
+
if (!current || current.connected)
|
|
1562
|
+
return;
|
|
1563
|
+
this.#emitTransportEvent({
|
|
1564
|
+
type: 'attempt-timeout',
|
|
1565
|
+
peerId,
|
|
1566
|
+
transport: transportKind,
|
|
1567
|
+
attemptIndex: attempt.index,
|
|
1568
|
+
order: [...attempt.order]
|
|
1569
|
+
});
|
|
1570
|
+
debug(`peer ${peerId} ${transportKind} connect timeout`);
|
|
1571
|
+
current.destroy();
|
|
1572
|
+
}, this.#transportConnectTimeoutMs);
|
|
1573
|
+
}
|
|
1574
|
+
#decodeCircuitFallbackPayload(payload) {
|
|
1575
|
+
if (!payload || typeof payload !== 'object')
|
|
1576
|
+
return null;
|
|
1577
|
+
const candidate = payload;
|
|
1578
|
+
if (!Array.isArray(candidate.data))
|
|
1579
|
+
return null;
|
|
1580
|
+
const id = typeof candidate.id === 'string' && candidate.id.length
|
|
1581
|
+
? candidate.id
|
|
1582
|
+
: this.#createCircuitId();
|
|
1583
|
+
return {
|
|
1584
|
+
id,
|
|
1585
|
+
data: new Uint8Array(candidate.data.map((byte) => Number(byte) & 255))
|
|
1586
|
+
};
|
|
1587
|
+
}
|
|
1588
|
+
async #handleFallbackCircuitPayload(payload, context) {
|
|
1589
|
+
const decoded = this.#decodeCircuitFallbackPayload(payload);
|
|
1590
|
+
if (!decoded) {
|
|
1591
|
+
throw new Error('invalid circuit fallback payload');
|
|
1592
|
+
}
|
|
1593
|
+
let peer = this.#connections[context.from];
|
|
1594
|
+
if (!peer) {
|
|
1595
|
+
peer = this.#createCircuitConnection(context.from, this.version);
|
|
1596
|
+
}
|
|
1597
|
+
this.#noticeMessage(decoded.data, decoded.id, context.from, peer);
|
|
1598
|
+
return { ok: true };
|
|
1599
|
+
}
|
|
1600
|
+
#createCircuitId() {
|
|
1601
|
+
const randomUUID = globalThis.crypto?.randomUUID;
|
|
1602
|
+
if (typeof randomUUID === 'function') {
|
|
1603
|
+
return randomUUID.call(globalThis.crypto);
|
|
1604
|
+
}
|
|
1605
|
+
return `circuit-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
1606
|
+
}
|
|
1607
|
+
#encodeCircuitData(data) {
|
|
1608
|
+
if (data instanceof Uint8Array) {
|
|
1609
|
+
return { __swarmType: 'u8', data: Array.from(data) };
|
|
1610
|
+
}
|
|
1611
|
+
return data;
|
|
1612
|
+
}
|
|
1613
|
+
#decodeCircuitData(data) {
|
|
1614
|
+
if (data &&
|
|
1615
|
+
typeof data === 'object' &&
|
|
1616
|
+
data.__swarmType === 'u8' &&
|
|
1617
|
+
Array.isArray(data.data)) {
|
|
1618
|
+
return new Uint8Array(data.data.map((item) => Number(item) & 255));
|
|
1619
|
+
}
|
|
1620
|
+
return data;
|
|
1621
|
+
}
|
|
1622
|
+
#pickStarConnection() {
|
|
1623
|
+
const entries = Object.values(this.#stars);
|
|
1624
|
+
return entries.find((client) => client?.connectionState?.() === 'open');
|
|
1625
|
+
}
|
|
1626
|
+
#sendCircuit(starConnection, payload) {
|
|
1627
|
+
starConnection.send({
|
|
1628
|
+
url: 'circuit',
|
|
1629
|
+
params: payload
|
|
1630
|
+
});
|
|
1631
|
+
}
|
|
1632
|
+
onCircuit(method, handler) {
|
|
1633
|
+
this.#circuitHandlers.set(method, handler);
|
|
1634
|
+
}
|
|
1635
|
+
offCircuit(method) {
|
|
1636
|
+
this.#circuitHandlers.delete(method);
|
|
1637
|
+
}
|
|
1638
|
+
onServerData(handler) {
|
|
1639
|
+
globalThis.pubsub.subscribe('server:data', handler);
|
|
1640
|
+
}
|
|
1641
|
+
offServerData(handler) {
|
|
1642
|
+
globalThis.pubsub.unsubscribe('server:data', handler);
|
|
1643
|
+
}
|
|
1644
|
+
circuitRequest(peerId, method, data, timeoutMs = this.#circuitRequestTimeoutMs) {
|
|
1645
|
+
const starConnection = this.#pickStarConnection();
|
|
1646
|
+
if (!starConnection) {
|
|
1647
|
+
return Promise.reject(new Error('no connected star available for circuit request'));
|
|
1648
|
+
}
|
|
1649
|
+
const id = this.#createCircuitId();
|
|
1650
|
+
return new Promise((resolve, reject) => {
|
|
1651
|
+
const timeout = setTimeout(() => {
|
|
1652
|
+
this.#pendingCircuitRequests.delete(id);
|
|
1653
|
+
reject(new Error(`circuit request for ${id} timed out`));
|
|
1654
|
+
}, timeoutMs);
|
|
1655
|
+
this.#pendingCircuitRequests.set(id, { resolve, reject, timeout });
|
|
1656
|
+
this.#sendCircuit(starConnection, {
|
|
1657
|
+
to: peerId,
|
|
1658
|
+
from: this.peerId,
|
|
1659
|
+
id,
|
|
1660
|
+
phase: 'request',
|
|
1661
|
+
method,
|
|
1662
|
+
data: this.#encodeCircuitData(data)
|
|
1663
|
+
});
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1666
|
+
#inComingCircuit = async ({ from, to, id, phase, method, data, error }, starConnection) => {
|
|
1667
|
+
if (to !== this.peerId)
|
|
1668
|
+
return;
|
|
1669
|
+
if (phase === 'response') {
|
|
1670
|
+
const pending = this.#pendingCircuitRequests.get(id);
|
|
1671
|
+
if (!pending)
|
|
1672
|
+
return;
|
|
1673
|
+
clearTimeout(pending.timeout);
|
|
1674
|
+
this.#pendingCircuitRequests.delete(id);
|
|
1675
|
+
if (error) {
|
|
1676
|
+
pending.reject(new Error(String(error)));
|
|
1677
|
+
return;
|
|
1678
|
+
}
|
|
1679
|
+
pending.resolve(this.#decodeCircuitData(data));
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
if (phase !== 'request')
|
|
1683
|
+
return;
|
|
1684
|
+
if (typeof method !== 'string' || !method.length)
|
|
1685
|
+
return;
|
|
1686
|
+
const handler = this.#circuitHandlers.get(method);
|
|
1687
|
+
if (!handler) {
|
|
1688
|
+
this.#sendCircuit(starConnection, {
|
|
1689
|
+
to: from,
|
|
1690
|
+
from: this.peerId,
|
|
1691
|
+
id,
|
|
1692
|
+
phase: 'response',
|
|
1693
|
+
error: `no circuit handler registered for method ${method}`
|
|
1694
|
+
});
|
|
1695
|
+
return;
|
|
1696
|
+
}
|
|
1697
|
+
try {
|
|
1698
|
+
const response = await handler(this.#decodeCircuitData(data), {
|
|
1699
|
+
from,
|
|
1700
|
+
method,
|
|
1701
|
+
id
|
|
1702
|
+
});
|
|
1703
|
+
this.#sendCircuit(starConnection, {
|
|
1704
|
+
to: from,
|
|
1705
|
+
from: this.peerId,
|
|
1706
|
+
id,
|
|
1707
|
+
phase: 'response',
|
|
1708
|
+
data: this.#encodeCircuitData(response)
|
|
1709
|
+
});
|
|
1710
|
+
}
|
|
1711
|
+
catch (handlerError) {
|
|
1712
|
+
this.#sendCircuit(starConnection, {
|
|
1713
|
+
to: from,
|
|
1714
|
+
from: this.peerId,
|
|
1715
|
+
id,
|
|
1716
|
+
phase: 'response',
|
|
1717
|
+
error: handlerError instanceof Error
|
|
1718
|
+
? handlerError.message
|
|
1719
|
+
: String(handlerError)
|
|
1720
|
+
});
|
|
1721
|
+
}
|
|
1722
|
+
};
|
|
1723
|
+
connect(peerId, star, initiator = true) {
|
|
1724
|
+
if (this.#connections[peerId]) {
|
|
1725
|
+
debug(`peer ${peerId} already connected`);
|
|
1726
|
+
return;
|
|
1727
|
+
}
|
|
1728
|
+
if (!this.#testHooks?.allowConnectWithoutStar &&
|
|
1729
|
+
this.#stars[star]?.connectionState() !== 'open') {
|
|
1730
|
+
console.warn(`Star ${star} is not connected, cannot reconnect to peer ${peerId}`);
|
|
1731
|
+
return;
|
|
1732
|
+
}
|
|
1733
|
+
this.#startPeerTransportAttempt(peerId, star, this.version, initiator, {
|
|
1734
|
+
kind: this.#preferredTransportKind,
|
|
1735
|
+
kinds: [...this.#supportedTransports]
|
|
1736
|
+
});
|
|
1737
|
+
}
|
|
1738
|
+
reconnect(peerId, star, initiator = false) {
|
|
1739
|
+
delete this.#connections[peerId];
|
|
1740
|
+
debug(`reconnecting to peer ${peerId}`);
|
|
1741
|
+
return this.connect(peerId, star, initiator);
|
|
1742
|
+
}
|
|
1743
|
+
connectWebTransport(peerId, url) {
|
|
1744
|
+
this.#createWebTransportConnection(peerId, url, this.version);
|
|
1745
|
+
}
|
|
1746
|
+
#resolveWebTransportUrl(peerId) {
|
|
1747
|
+
const urlTemplate = this.#webTransportUrlTemplate;
|
|
1748
|
+
if (!urlTemplate) {
|
|
1749
|
+
console.warn('WebTransport requires transport.webtransport.urlTemplate in client options');
|
|
1750
|
+
return null;
|
|
1751
|
+
}
|
|
1752
|
+
return urlTemplate
|
|
1753
|
+
.replace('{peerId}', encodeURIComponent(peerId))
|
|
1754
|
+
.replace('{selfId}', encodeURIComponent(this.peerId));
|
|
1755
|
+
}
|
|
1756
|
+
#createWebTransportConnection(peerId, url, version) {
|
|
1757
|
+
if (this.#connections[peerId]) {
|
|
1758
|
+
debug(`peer ${peerId} already connected`);
|
|
1759
|
+
return null;
|
|
1760
|
+
}
|
|
1761
|
+
const testPeer = this.#testHooks?.createPeer?.({
|
|
1762
|
+
kind: 'webtransport',
|
|
1763
|
+
peerId,
|
|
1764
|
+
version,
|
|
1765
|
+
initiator: true,
|
|
1766
|
+
url
|
|
1767
|
+
});
|
|
1768
|
+
if (testPeer) {
|
|
1769
|
+
testPeer.on('connect', () => this.#peerConnect(testPeer));
|
|
1770
|
+
testPeer.on('close', () => this.#peerClose(testPeer));
|
|
1771
|
+
testPeer.on('data', (data) => this.#peerData(testPeer, data));
|
|
1772
|
+
testPeer.on('error', (error) => this.#peerError(testPeer, error));
|
|
1773
|
+
this.#connections[peerId] = testPeer;
|
|
1774
|
+
this.#peerTransportKinds.set(peerId, 'webtransport');
|
|
1775
|
+
return testPeer;
|
|
1776
|
+
}
|
|
1777
|
+
const peer = new WebTransportPeer({
|
|
1778
|
+
from: this.peerId,
|
|
1779
|
+
to: peerId,
|
|
1780
|
+
version,
|
|
1781
|
+
url
|
|
1782
|
+
});
|
|
1783
|
+
peer.on('connect', () => this.#peerConnect(peer));
|
|
1784
|
+
peer.on('close', () => this.#peerClose(peer));
|
|
1785
|
+
peer.on('data', (data) => this.#peerData(peer, data));
|
|
1786
|
+
peer.on('error', (error) => this.#peerError(peer, error));
|
|
1787
|
+
this.#connections[peerId] = peer;
|
|
1788
|
+
this.#peerTransportKinds.set(peerId, 'webtransport');
|
|
1789
|
+
return peer;
|
|
1790
|
+
}
|
|
1791
|
+
#createCircuitConnection(peerId, version) {
|
|
1792
|
+
if (this.#connections[peerId]) {
|
|
1793
|
+
debug(`peer ${peerId} already connected`);
|
|
1794
|
+
return null;
|
|
1795
|
+
}
|
|
1796
|
+
const testPeer = this.#testHooks?.createPeer?.({
|
|
1797
|
+
kind: 'circuit',
|
|
1798
|
+
peerId,
|
|
1799
|
+
version,
|
|
1800
|
+
initiator: true
|
|
1801
|
+
});
|
|
1802
|
+
if (testPeer) {
|
|
1803
|
+
testPeer.on('connect', () => this.#peerConnect(testPeer));
|
|
1804
|
+
testPeer.on('close', () => this.#peerClose(testPeer));
|
|
1805
|
+
testPeer.on('data', (data) => this.#peerData(testPeer, data));
|
|
1806
|
+
testPeer.on('error', (error) => this.#peerError(testPeer, error));
|
|
1807
|
+
this.#connections[peerId] = testPeer;
|
|
1808
|
+
this.#peerTransportKinds.set(peerId, 'circuit');
|
|
1809
|
+
return testPeer;
|
|
1810
|
+
}
|
|
1811
|
+
const peer = new CircuitPeer({
|
|
1812
|
+
from: this.peerId,
|
|
1813
|
+
to: peerId,
|
|
1814
|
+
version,
|
|
1815
|
+
sendViaCircuit: (payload) => this.circuitRequest(peerId, this.#fallbackCircuitMethod, payload)
|
|
1816
|
+
});
|
|
1817
|
+
peer.on('connect', () => this.#peerConnect(peer));
|
|
1818
|
+
peer.on('close', () => this.#peerClose(peer));
|
|
1819
|
+
peer.on('error', (error) => this.#peerError(peer, error));
|
|
1820
|
+
this.#connections[peerId] = peer;
|
|
1821
|
+
this.#peerTransportKinds.set(peerId, 'circuit');
|
|
1822
|
+
return peer;
|
|
1823
|
+
}
|
|
1824
|
+
#createRTCPeerConnection = (peerId, star, version, initiator = false) => {
|
|
1825
|
+
const testPeer = this.#testHooks?.createPeer?.({
|
|
1826
|
+
kind: 'webrtc',
|
|
1827
|
+
peerId,
|
|
1828
|
+
version,
|
|
1829
|
+
initiator,
|
|
1830
|
+
star
|
|
1831
|
+
});
|
|
1832
|
+
if (testPeer) {
|
|
1833
|
+
testPeer.on('connect', () => this.#peerConnect(testPeer));
|
|
1834
|
+
testPeer.on('close', () => this.#peerClose(testPeer));
|
|
1835
|
+
testPeer.on('data', (data) => this.#peerData(testPeer, data));
|
|
1836
|
+
testPeer.on('error', (error) => this.#peerError(testPeer, error));
|
|
1837
|
+
this.#connections[peerId] = testPeer;
|
|
1838
|
+
this.#peerTransportKinds.set(peerId, 'webrtc');
|
|
1839
|
+
return testPeer;
|
|
1840
|
+
}
|
|
1841
|
+
const peer = new WebRTCPeer({
|
|
1842
|
+
initiator: initiator,
|
|
1843
|
+
from: this.peerId,
|
|
1844
|
+
to: peerId,
|
|
1845
|
+
version
|
|
1846
|
+
});
|
|
1847
|
+
peer.on('signal', (signal) => this.#peerSignal(peer, signal, star, this.version));
|
|
1848
|
+
peer.on('connect', () => this.#peerConnect(peer));
|
|
1849
|
+
peer.on('close', () => this.#peerClose(peer));
|
|
1850
|
+
peer.on('data', (data) => this.#peerData(peer, data));
|
|
1851
|
+
peer.on('error', (error) => this.#peerError(peer, error));
|
|
1852
|
+
this.#connections[peerId] = peer;
|
|
1853
|
+
this.#peerTransportKinds.set(peerId, 'webrtc');
|
|
1854
|
+
return peer;
|
|
1855
|
+
};
|
|
1856
|
+
#peerJoined = async ({ peerId, version, transport }, star) => {
|
|
1857
|
+
// check if peer rejoined before the previous connection closed
|
|
1858
|
+
if (this.#connections[peerId]) {
|
|
1859
|
+
this.#connections[peerId].destroy();
|
|
1860
|
+
delete this.#connections[peerId];
|
|
1861
|
+
}
|
|
1862
|
+
if (this.peerId === peerId)
|
|
1863
|
+
return;
|
|
1864
|
+
this.#startPeerTransportAttempt(peerId, star, version, true, transport);
|
|
1865
|
+
debug(`peer ${peerId} joined`);
|
|
1866
|
+
};
|
|
1867
|
+
#inComingSignal = async ({ from, signal, channelName, version }, star) => {
|
|
1868
|
+
if (!this.#supportsTransport('webrtc')) {
|
|
1869
|
+
debug(`ignoring webrtc signal for ${from} because local client does not support webrtc`);
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
if (version !== this.version) {
|
|
1873
|
+
console.warn(`${from} joined using the wrong version.\nexpected: ${this.version} but got:${version}`);
|
|
1874
|
+
return;
|
|
1875
|
+
}
|
|
1876
|
+
if (from === this.peerId) {
|
|
1877
|
+
console.warn(`${from} tried to connect to itself.`);
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
let peer = this.#connections[from];
|
|
1881
|
+
if (peer && String(peer.channelName).startsWith('webtransport:')) {
|
|
1882
|
+
if (peer.connected) {
|
|
1883
|
+
debug(`ignoring webrtc signal for ${from} because peer uses webtransport`);
|
|
1884
|
+
return;
|
|
1885
|
+
}
|
|
1886
|
+
peer.destroy();
|
|
1887
|
+
delete this.#connections[from];
|
|
1888
|
+
this.#clearTransportAttempt(from);
|
|
1889
|
+
peer = undefined;
|
|
1890
|
+
}
|
|
1891
|
+
if (!peer) {
|
|
1892
|
+
this.#clearTransportAttempt(from);
|
|
1893
|
+
this.#createRTCPeerConnection(from, star, version);
|
|
1894
|
+
peer = this.#connections[from];
|
|
1895
|
+
}
|
|
1896
|
+
if (peer.connected) {
|
|
1897
|
+
debug(`peer ${from} already connected`);
|
|
1898
|
+
return;
|
|
1899
|
+
}
|
|
1900
|
+
// peer.channels[channelName]
|
|
1901
|
+
if (String(peer.channelName) !== String(channelName)) {
|
|
1902
|
+
console.warn(`channelNames don't match: got ${peer.channelName}, expected: ${channelName}.`);
|
|
1903
|
+
peer.destroy();
|
|
1904
|
+
delete this.#connections[from];
|
|
1905
|
+
this.#clearTransportAttempt(from);
|
|
1906
|
+
this.#createRTCPeerConnection(from, star, version, false);
|
|
1907
|
+
peer = this.#connections[from];
|
|
1908
|
+
if (!peer)
|
|
1909
|
+
return;
|
|
1910
|
+
}
|
|
1911
|
+
peer.signal(signal);
|
|
1912
|
+
};
|
|
1913
|
+
#peerSignal = (peer, signal, star, version) => {
|
|
1914
|
+
let client = this.#stars[star];
|
|
1915
|
+
if (!client)
|
|
1916
|
+
client = this.#stars[Object.keys(this.#stars)[0]];
|
|
1917
|
+
client.send({
|
|
1918
|
+
url: 'signal',
|
|
1919
|
+
params: {
|
|
1920
|
+
from: this.peerId,
|
|
1921
|
+
to: peer.peerId,
|
|
1922
|
+
channelName: peer.channelName,
|
|
1923
|
+
version,
|
|
1924
|
+
signal,
|
|
1925
|
+
initiator: peer.initiator
|
|
1926
|
+
}
|
|
1927
|
+
});
|
|
1928
|
+
};
|
|
1929
|
+
#peerClose = (peer) => {
|
|
1930
|
+
const wasConnected = peer.connected;
|
|
1931
|
+
this.#peerTransportKinds.delete(peer.peerId);
|
|
1932
|
+
if (this.#connections[peer.peerId]) {
|
|
1933
|
+
peer.destroy();
|
|
1934
|
+
delete this.#connections[peer.peerId];
|
|
1935
|
+
}
|
|
1936
|
+
if (!wasConnected && this.#peerTransportAttempts.has(peer.peerId)) {
|
|
1937
|
+
this.#advanceTransportAttempt(peer.peerId);
|
|
1938
|
+
}
|
|
1939
|
+
debug(`closed ${peer.peerId}'s connection`);
|
|
1940
|
+
};
|
|
1941
|
+
#peerConnect = (peer) => {
|
|
1942
|
+
const attempt = this.#peerTransportAttempts.get(peer.peerId);
|
|
1943
|
+
const transport = this.#peerTransportKinds.get(peer.peerId) ||
|
|
1944
|
+
attempt?.order[attempt.index] ||
|
|
1945
|
+
'webrtc';
|
|
1946
|
+
this.#emitTransportEvent({
|
|
1947
|
+
type: 'connected',
|
|
1948
|
+
peerId: peer.peerId,
|
|
1949
|
+
transport,
|
|
1950
|
+
attemptIndex: attempt?.index ?? 0,
|
|
1951
|
+
order: attempt ? [...attempt.order] : [transport]
|
|
1952
|
+
});
|
|
1953
|
+
this.#clearTransportAttempt(peer.peerId);
|
|
1954
|
+
debug(`${peer.peerId} connected`);
|
|
1955
|
+
globalThis.pubsub.publishVerbose(this.#connectEvent, peer.peerId);
|
|
1956
|
+
};
|
|
1957
|
+
#noticeMessage = (message, id, from, peer) => {
|
|
1958
|
+
const dataOut = message instanceof Uint8Array
|
|
1959
|
+
? message
|
|
1960
|
+
: new Uint8Array(Object.values(message));
|
|
1961
|
+
if (globalThis.pubsub.hasSubscribers(id)) {
|
|
1962
|
+
globalThis.pubsub.publish(id, {
|
|
1963
|
+
data: dataOut,
|
|
1964
|
+
id,
|
|
1965
|
+
from,
|
|
1966
|
+
peer
|
|
1967
|
+
});
|
|
1968
|
+
}
|
|
1969
|
+
else {
|
|
1970
|
+
globalThis.pubsub.publish('peer:data', {
|
|
1971
|
+
data: dataOut,
|
|
1972
|
+
id,
|
|
1973
|
+
from,
|
|
1974
|
+
peer
|
|
1975
|
+
});
|
|
1976
|
+
}
|
|
1977
|
+
};
|
|
1978
|
+
#peerData = (peer, data) => {
|
|
1979
|
+
const tryJson = () => {
|
|
1980
|
+
const parsed = JSON.parse(new TextDecoder().decode(data));
|
|
1981
|
+
const { id, size, chunk, index, count } = parsed;
|
|
1982
|
+
chunk ? Object.values(chunk).length : size;
|
|
1983
|
+
return {
|
|
1984
|
+
id,
|
|
1985
|
+
size: Number(size),
|
|
1986
|
+
index: Number(index ?? 0),
|
|
1987
|
+
count: Number(count ?? 1),
|
|
1988
|
+
chunk: new Uint8Array(Object.values(chunk)),
|
|
1989
|
+
flags: 0,
|
|
1990
|
+
crc: 0
|
|
1991
|
+
};
|
|
1992
|
+
};
|
|
1993
|
+
const decodeBinary = () => {
|
|
1994
|
+
let u8;
|
|
1995
|
+
if (typeof data === 'string') {
|
|
1996
|
+
// should not happen when sending binary, fallback to JSON
|
|
1997
|
+
return tryJson();
|
|
1998
|
+
}
|
|
1999
|
+
else if (data instanceof ArrayBuffer) {
|
|
2000
|
+
u8 = new Uint8Array(data);
|
|
2001
|
+
}
|
|
2002
|
+
else if (ArrayBuffer.isView(data)) {
|
|
2003
|
+
const view = data;
|
|
2004
|
+
const byteOffset = view.byteOffset || 0;
|
|
2005
|
+
const byteLength = view.byteLength || data.length;
|
|
2006
|
+
u8 = new Uint8Array(view.buffer, byteOffset, byteLength);
|
|
2007
|
+
}
|
|
2008
|
+
else if (data?.buffer) {
|
|
2009
|
+
u8 = new Uint8Array(data.buffer);
|
|
2010
|
+
}
|
|
2011
|
+
else {
|
|
2012
|
+
// last resort: attempt JSON
|
|
2013
|
+
return tryJson();
|
|
2014
|
+
}
|
|
2015
|
+
const dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
|
|
2016
|
+
let offset = 0;
|
|
2017
|
+
dv.getUint8(offset);
|
|
2018
|
+
offset += 1;
|
|
2019
|
+
const flags = dv.getUint8(offset);
|
|
2020
|
+
offset += 1;
|
|
2021
|
+
const size = dv.getUint32(offset, true);
|
|
2022
|
+
offset += 4;
|
|
2023
|
+
const index = dv.getUint32(offset, true);
|
|
2024
|
+
offset += 4;
|
|
2025
|
+
const count = dv.getUint32(offset, true);
|
|
2026
|
+
offset += 4;
|
|
2027
|
+
const expectedCrc = dv.getUint32(offset, true);
|
|
2028
|
+
offset += 4;
|
|
2029
|
+
const idLen = dv.getUint16(offset, true);
|
|
2030
|
+
offset += 2;
|
|
2031
|
+
const idBytes = u8.subarray(offset, offset + idLen);
|
|
2032
|
+
offset += idLen;
|
|
2033
|
+
const id = new TextDecoder().decode(idBytes);
|
|
2034
|
+
const chunk = u8.subarray(offset);
|
|
2035
|
+
return { id, size, index, count, chunk, flags, crc: expectedCrc };
|
|
2036
|
+
};
|
|
2037
|
+
const frame = decodeBinary();
|
|
2038
|
+
peer.bw.down += frame.chunk.length;
|
|
2039
|
+
// Single frame path: if compressed, inflate before publish
|
|
2040
|
+
if (frame.count === 1) {
|
|
2041
|
+
let payload = frame.chunk;
|
|
2042
|
+
const compressed = Boolean(frame.flags & (1 << 1));
|
|
2043
|
+
if (compressed) {
|
|
2044
|
+
const actualCrc = crc32(payload);
|
|
2045
|
+
if (actualCrc !== frame.crc) {
|
|
2046
|
+
console.warn(`CRC mismatch: expected ${frame.crc}, got ${actualCrc}`);
|
|
2047
|
+
}
|
|
2048
|
+
try {
|
|
2049
|
+
payload = inflate_1(payload);
|
|
2050
|
+
}
|
|
2051
|
+
catch (e) {
|
|
2052
|
+
console.warn('inflate failed, passing compressed payload');
|
|
2053
|
+
}
|
|
2054
|
+
}
|
|
2055
|
+
this.#noticeMessage(payload, frame.id, peer.peerId, peer);
|
|
2056
|
+
return;
|
|
2057
|
+
}
|
|
2058
|
+
// Chunked message handling with indexed reassembly
|
|
2059
|
+
if (!this.#messagesToHandle[frame.id] ||
|
|
2060
|
+
Array.isArray(this.#messagesToHandle[frame.id])) {
|
|
2061
|
+
this.#messagesToHandle[frame.id] = {
|
|
2062
|
+
chunks: new Array(frame.count),
|
|
2063
|
+
receivedBytes: 0,
|
|
2064
|
+
expectedSize: Number(frame.size),
|
|
2065
|
+
expectedCount: Number(frame.count)
|
|
2066
|
+
};
|
|
2067
|
+
}
|
|
2068
|
+
const state = this.#messagesToHandle[frame.id];
|
|
2069
|
+
// Verify CRC for this chunk
|
|
2070
|
+
const actualCrc = crc32(frame.chunk);
|
|
2071
|
+
if (actualCrc !== frame.crc) {
|
|
2072
|
+
console.warn(`Chunk CRC mismatch for ${frame.id}[${frame.index}]: expected ${frame.crc}, got ${actualCrc}`);
|
|
2073
|
+
}
|
|
2074
|
+
state.chunks[frame.index] = frame.chunk;
|
|
2075
|
+
state.receivedBytes += frame.chunk.length;
|
|
2076
|
+
// If all chunks present and total size matches, reassemble
|
|
2077
|
+
const allPresent = state.chunks.every((c) => c instanceof Uint8Array);
|
|
2078
|
+
if (allPresent && state.receivedBytes === state.expectedSize) {
|
|
2079
|
+
const result = new Uint8Array(state.expectedSize);
|
|
2080
|
+
let offset2 = 0;
|
|
2081
|
+
for (const c of state.chunks) {
|
|
2082
|
+
result.set(c, offset2);
|
|
2083
|
+
offset2 += c.length;
|
|
2084
|
+
}
|
|
2085
|
+
let payload = result;
|
|
2086
|
+
const compressed = Boolean(frame.flags & (1 << 1));
|
|
2087
|
+
if (compressed) {
|
|
2088
|
+
try {
|
|
2089
|
+
payload = inflate_1(result);
|
|
2090
|
+
}
|
|
2091
|
+
catch (e) {
|
|
2092
|
+
console.warn('inflate failed, passing compressed payload');
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
2095
|
+
this.#noticeMessage(payload, frame.id, peer.peerId, peer);
|
|
2096
|
+
delete this.#messagesToHandle[frame.id];
|
|
2097
|
+
}
|
|
2098
|
+
};
|
|
2099
|
+
#peerError = (peer, error) => {
|
|
2100
|
+
const message = error instanceof Error ? error.message : `unknown error: ${String(error)}`;
|
|
2101
|
+
const attempt = this.#peerTransportAttempts.get(peer.peerId);
|
|
2102
|
+
const transport = this.#peerTransportKinds.get(peer.peerId) ||
|
|
2103
|
+
attempt?.order[attempt.index] ||
|
|
2104
|
+
'webrtc';
|
|
2105
|
+
this.#emitTransportEvent({
|
|
2106
|
+
type: 'attempt-error',
|
|
2107
|
+
peerId: peer.peerId,
|
|
2108
|
+
transport,
|
|
2109
|
+
attemptIndex: attempt?.index ?? 0,
|
|
2110
|
+
order: attempt ? [...attempt.order] : [transport],
|
|
2111
|
+
reason: message
|
|
2112
|
+
});
|
|
2113
|
+
console.warn(`Connection error: ${message}`);
|
|
2114
|
+
peer.destroy();
|
|
2115
|
+
};
|
|
2116
|
+
async close() {
|
|
2117
|
+
for (const peerId of this.#peerTransportAttempts.keys()) {
|
|
2118
|
+
this.#clearTransportAttempt(peerId);
|
|
2119
|
+
}
|
|
2120
|
+
for (const peerId in this.#connections) {
|
|
2121
|
+
const peer = this.#connections[peerId];
|
|
2122
|
+
if (peer) {
|
|
2123
|
+
peer.destroy();
|
|
2124
|
+
delete this.#connections[peerId];
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
for (const star in this.#stars) {
|
|
2128
|
+
// unsubscribe handlers we registered earlier
|
|
2129
|
+
const listeners = this.#starListeners[star];
|
|
2130
|
+
if (listeners && listeners.length) {
|
|
2131
|
+
for (const { topic, handler } of listeners) {
|
|
2132
|
+
try {
|
|
2133
|
+
this.#stars[star].pubsub.unsubscribe(topic, handler);
|
|
2134
|
+
}
|
|
2135
|
+
catch (e) {
|
|
2136
|
+
// ignore
|
|
2137
|
+
}
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
if (this.#stars[star].connectionState() === 'open') {
|
|
2141
|
+
await this.#stars[star].send({ url: 'leave', params: this.peerId });
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
const peerClosers = Object.values(this.#connections).map((connection) => {
|
|
2145
|
+
try {
|
|
2146
|
+
// destroy() may be sync or return a promise
|
|
2147
|
+
return connection.destroy();
|
|
2148
|
+
}
|
|
2149
|
+
catch (e) {
|
|
2150
|
+
return undefined;
|
|
2151
|
+
}
|
|
2152
|
+
});
|
|
2153
|
+
const starClosers = Object.values(this.#stars).map((connection) => {
|
|
2154
|
+
try {
|
|
2155
|
+
return connection.close(0);
|
|
2156
|
+
}
|
|
2157
|
+
catch (e) {
|
|
2158
|
+
return undefined;
|
|
2159
|
+
}
|
|
2160
|
+
});
|
|
2161
|
+
return Promise.allSettled([...peerClosers, ...starClosers]);
|
|
2162
|
+
}
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
export { Client as default };
|