@trystero-p2p/torrent 0.23.0 β†’ 0.24.0

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 ADDED
@@ -0,0 +1,1113 @@
1
+ # ✨🀝✨ Trystero
2
+
3
+ **Build instant multiplayer web apps, no server required**
4
+
5
+ πŸ‘‰ **[Try it live on trystero.dev](https://trystero.dev)** πŸ‘ˆ
6
+
7
+ Trystero makes browsers discover each other and communicate directly. No
8
+ accounts. No deploying infrastructure. Just import and connect.
9
+
10
+ Peers can connect via 🌊 BitTorrent, 🐦 Nostr, πŸ“‘ MQTT, ⚑️ Supabase, πŸ”₯Firebase,
11
+ πŸͺ IPFS, or a πŸ”Œ self-hosted WebSocket relay – all using the same API.
12
+
13
+ Besides making peer matching automatic, Trystero offers some nice abstractions
14
+ on top of WebRTC:
15
+
16
+ - πŸ‘‚πŸ“£ Rooms / broadcasting
17
+ - πŸ”’πŸ“© Automatic serialization / deserialization of data
18
+ - πŸŽ₯🏷 Attach metadata to binary data and media streams
19
+ - βœ‚οΈβ³ Automatic chunking and throttling of large data
20
+ - ⏱🀞 Progress events and promises for data transfers
21
+ - πŸ”πŸ“ Session data encryption
22
+ - 🏭⚑ Can run peers server-side on Node and Bun
23
+ - βš›οΈπŸͺ React hooks
24
+
25
+ You can see what people are building with Trystero
26
+ [here](https://github.com/jeremyckahn/awesome-trystero).
27
+
28
+ ---
29
+
30
+ ## Contents
31
+
32
+ - [How it works](#how-it-works)
33
+ - [Get started](#get-started)
34
+ - [Listen for events](#listen-for-events)
35
+ - [Broadcast events](#broadcast-events)
36
+ - [Audio and video](#audio-and-video)
37
+ - [Advanced](#advanced)
38
+ - [Binary metadata](#binary-metadata)
39
+ - [Action promises](#action-promises)
40
+ - [Progress updates](#progress-updates)
41
+ - [Encryption](#encryption)
42
+ - [React hooks](#react-hooks)
43
+ - [Troubleshooting connection issues](#troubleshooting-connection-issues)
44
+ - [Running server-side (Node, Bun)](#running-server-side-node-bun)
45
+ - [Self-hosted WebSocket relay](#self-hosted-websocket-relay)
46
+ - [Write your own strategy](#write-your-own-strategy)
47
+ - [Supabase setup](#supabase-setup)
48
+ - [Firebase setup](#firebase-setup)
49
+ - [API](#api)
50
+ - [`joinRoom(config, roomId, [callbacks])`](#joinroomconfig-roomid-callbacks)
51
+ - [`selfId`](#selfid)
52
+ - [`getRelaySockets()`](#getrelaysockets)
53
+ - [`pauseRelayReconnection()`](#pauserelayreconnection)
54
+ - [`resumeRelayReconnection()`](#resumerelayreconnection)
55
+ - [Which strategy should I choose?](#which-strategy-should-i-choose)
56
+
57
+ ---
58
+
59
+ ## How it works
60
+
61
+ πŸ‘‰ **If you just want to try out Trystero, you can skip this explainer and
62
+ [jump into using it](#get-started).**
63
+
64
+ To establish a direct peer-to-peer connection with WebRTC, a signalling channel
65
+ is needed to exchange peer information
66
+ ([SDP](https://en.wikipedia.org/wiki/Session_Description_Protocol)). Typically
67
+ this involves running your own matchmaking server but Trystero abstracts this
68
+ away for you and offers multiple strategies for connecting peers (currently
69
+ BitTorrent, Nostr, MQTT, Supabase, Firebase, IPFS, and self-hosted WebSocket
70
+ relay).
71
+
72
+ The important point to remember is this:
73
+
74
+ > πŸ”’
75
+ >
76
+ > Beyond peer discovery, your app's data never touches the strategy medium and
77
+ > is sent directly peer-to-peer and end-to-end encrypted between users.
78
+ >
79
+ > πŸ‘†
80
+
81
+ You can [compare strategies here](#which-strategy-should-i-choose).
82
+
83
+ ## Get started
84
+
85
+ Install Trystero with your preferred package manager, then import it in your
86
+ code:
87
+
88
+ ```sh
89
+ npm i trystero
90
+ ```
91
+
92
+ ```js
93
+ import {joinRoom} from 'trystero'
94
+ ```
95
+
96
+ No package manager? You can also use a CDN:
97
+
98
+ ```html
99
+ <script type="module">
100
+ import {joinRoom} from 'https://esm.run/trystero'
101
+ </script>
102
+ ```
103
+
104
+ The default Trystero package runs on the Nostr network, but you can swap in any
105
+ other stategy by changing which package you import:
106
+
107
+ ```js
108
+ import {joinRoom} from '@trystero-p2p/mqtt'
109
+ // or
110
+ import {joinRoom} from '@trystero-p2p/torrent'
111
+ // or
112
+ import {joinRoom} from '@trystero-p2p/supabase'
113
+ // or
114
+ import {joinRoom} from '@trystero-p2p/firebase'
115
+ // or
116
+ import {joinRoom} from '@trystero-p2p/ipfs'
117
+ // or
118
+ import {joinRoom} from '@trystero-p2p/ws-relay'
119
+ ```
120
+
121
+ Next, join the user to a room with an ID:
122
+
123
+ ```js
124
+ const config = {appId: 'san_narciso_3d'}
125
+ const room = joinRoom(config, 'yoyodyne')
126
+ ```
127
+
128
+ The first argument is a configuration object that requires an `appId`. This
129
+ should be a completely unique identifier for your appΒΉ. The second argument is
130
+ the room ID.
131
+
132
+ > Why rooms? Browsers can only handle a limited amount of WebRTC connections at
133
+ > a time so it's recommended to design your app such that users are divided into
134
+ > groups (or rooms, or namespaces, or channels... whatever you'd like to call
135
+ > them).
136
+
137
+ ΒΉ When using Firebase, `appId` should be your `databaseURL` and when using
138
+ Supabase, it should be your project URL.
139
+
140
+ ## Listen for events
141
+
142
+ Listen for peers joining the room:
143
+
144
+ ```js
145
+ room.onPeerJoin(peerId => console.log(`${peerId} joined`))
146
+ ```
147
+
148
+ Listen for peers leaving the room:
149
+
150
+ ```js
151
+ room.onPeerLeave(peerId => console.log(`${peerId} left`))
152
+ ```
153
+
154
+ Listen for peers sending their audio/video streams:
155
+
156
+ ```js
157
+ room.onPeerStream(
158
+ (stream, peerId) => (peerElements[peerId].video.srcObject = stream)
159
+ )
160
+ ```
161
+
162
+ To unsubscribe from events, leave the room:
163
+
164
+ ```js
165
+ room.leave()
166
+ ```
167
+
168
+ You can access the local user's peer ID by importing `selfId` like so:
169
+
170
+ ```js
171
+ import {selfId} from 'trystero'
172
+
173
+ console.log(`my peer ID is ${selfId}`)
174
+ ```
175
+
176
+ ## Broadcast events
177
+
178
+ Send peers your video stream:
179
+
180
+ ```js
181
+ const stream = await navigator.mediaDevices.getUserMedia({
182
+ audio: true,
183
+ video: true
184
+ })
185
+ room.addStream(stream)
186
+ ```
187
+
188
+ Send and subscribe to custom peer-to-peer actions:
189
+
190
+ ```js
191
+ const [sendDrink, getDrink] = room.makeAction('drink')
192
+
193
+ // buy drink for a friend
194
+ sendDrink({drink: 'negroni', withIce: true}, friendId)
195
+
196
+ // buy round for the house (second argument omitted)
197
+ sendDrink({drink: 'mezcal', withIce: false})
198
+
199
+ // listen for drinks sent to you
200
+ getDrink((data, peerId) =>
201
+ console.log(
202
+ `got a ${data.drink} with${data.withIce ? '' : 'out'} ice from ${peerId}`
203
+ )
204
+ )
205
+ ```
206
+
207
+ If you're using TypeScript, you can add a type hint to the action:
208
+
209
+ ```typescript
210
+ type CursorMove = {x: number; y: number}
211
+
212
+ const [sendCursor, getCursor] = room.makeAction<CursorMove>('cursor-move')
213
+ ```
214
+
215
+ You can also use actions to send binary data, like images:
216
+
217
+ ```js
218
+ const [sendPic, getPic] = room.makeAction('pic')
219
+
220
+ // blobs are automatically handled, as are any form of TypedArray
221
+ canvas.toBlob(blob => sendPic(blob))
222
+
223
+ // binary data is received as raw ArrayBuffers so your handling code should
224
+ // interpret it in a way that makes sense
225
+ getPic(
226
+ (data, peerId) => (imgs[peerId].src = URL.createObjectURL(new Blob([data])))
227
+ )
228
+ ```
229
+
230
+ Let's say we want users to be able to name themselves:
231
+
232
+ ```js
233
+ const idsToNames = {}
234
+ const [sendName, getName] = room.makeAction('name')
235
+
236
+ // tell new peers your name when they connect
237
+ room.onPeerJoin(peerId => sendName('Oedipa', peerId))
238
+
239
+ // listen for peers naming themselves
240
+ getName((name, peerId) => (idsToNames[peerId] = name))
241
+
242
+ // tell all peers at once when your name changes
243
+ nameInput.addEventListener('change', e => sendName(e.target.value))
244
+
245
+ room.onPeerLeave(peerId =>
246
+ console.log(`${idsToNames[peerId] || 'a weird stranger'} left`)
247
+ )
248
+ ```
249
+
250
+ > Actions are smart and handle serialization and chunking for you behind the
251
+ > scenes. This means you can send very large files and whatever data you send
252
+ > will be received on the other side as the same type (a number as a number, a
253
+ > string as a string, an object as an object, binary as binary, etc.).
254
+
255
+ ## Audio and video
256
+
257
+ Here's a simple example of how you could create an audio chatroom:
258
+
259
+ ```js
260
+ // this object can store audio instances for later
261
+ const peerAudios = {}
262
+
263
+ // get a local audio stream from the microphone
264
+ const selfStream = await navigator.mediaDevices.getUserMedia({
265
+ audio: true,
266
+ video: false
267
+ })
268
+
269
+ // send stream to peers currently in the room
270
+ room.addStream(selfStream)
271
+
272
+ // send stream to peers who join later
273
+ room.onPeerJoin(peerId => room.addStream(selfStream, peerId))
274
+
275
+ // handle streams from other peers
276
+ room.onPeerStream((stream, peerId) => {
277
+ // create an audio instance and set the incoming stream
278
+ const audio = new Audio()
279
+ audio.srcObject = stream
280
+ audio.autoplay = true
281
+
282
+ // add the audio to peerAudios object if you want to address it for something
283
+ // later (volume, etc.)
284
+ peerAudios[peerId] = audio
285
+ })
286
+ ```
287
+
288
+ Doing the same with video is similar, just be sure to add incoming streams to
289
+ video elements in the DOM:
290
+
291
+ ```js
292
+ const peerVideos = {}
293
+ const videoContainer = document.getElementById('videos')
294
+
295
+ room.onPeerStream((stream, peerId) => {
296
+ let video = peerVideos[peerId]
297
+
298
+ // if this peer hasn't sent a stream before, create a video element
299
+ if (!video) {
300
+ video = document.createElement('video')
301
+ video.autoplay = true
302
+
303
+ // add video element to the DOM
304
+ videoContainer.appendChild(video)
305
+ }
306
+
307
+ video.srcObject = stream
308
+ peerVideos[peerId] = video
309
+ })
310
+ ```
311
+
312
+ ## Advanced
313
+
314
+ ### Binary metadata
315
+
316
+ Let's say your app supports sending various types of files and you want to
317
+ annotate the raw bytes being sent with metadata about how they should be
318
+ interpreted. Instead of manually adding metadata bytes to the buffer you can
319
+ simply pass a metadata argument in the sender action for your binary payload:
320
+
321
+ ```js
322
+ const [sendFile, getFile] = room.makeAction('file')
323
+
324
+ getFile((data, peerId, metadata) =>
325
+ console.log(
326
+ `got a file (${metadata.name}) from ${peerId} with type ${metadata.type}`,
327
+ data
328
+ )
329
+ )
330
+
331
+ // to send metadata, pass a third argument
332
+ // to broadcast to the whole room, set the second peer ID argument to null
333
+ sendFile(buffer, null, {name: 'The CourierΚΌs Tragedy', type: 'application/pdf'})
334
+ ```
335
+
336
+ ### Action promises
337
+
338
+ Action sender functions return a promise that resolves when they're done
339
+ sending. You can optionally use this to indicate to the user when a large
340
+ transfer is done.
341
+
342
+ ```js
343
+ await sendFile(amplePayload)
344
+ console.log('done sending to all peers')
345
+ ```
346
+
347
+ ### Progress updates
348
+
349
+ Action sender functions also take an optional callback function that will be
350
+ continuously called as the transmission progresses. This can be used for showing
351
+ a progress bar to the sender for large transfers. The callback is called with a
352
+ percentage value between 0 and 1 and the receiving peer's ID:
353
+
354
+ ```js
355
+ sendFile(
356
+ payload,
357
+ // notice the peer target argument for any action sender can be a single peer
358
+ // ID, an array of IDs, or null (meaning send to all peers in the room)
359
+ [peerIdA, peerIdB, peerIdC],
360
+ // metadata, which can also be null if you're only interested in the
361
+ // progress handler
362
+ {filename: 'paranoids.flac'},
363
+ // assuming each peer has a loading bar added to the DOM, its value is
364
+ // updated here
365
+ (percent, peerId) => (loadingBars[peerId].value = percent)
366
+ )
367
+ ```
368
+
369
+ Similarly you can listen for progress events as a receiver like this:
370
+
371
+ ```js
372
+ const [sendFile, getFile, onFileProgress] = room.makeAction('file')
373
+
374
+ onFileProgress((percent, peerId, metadata) =>
375
+ console.log(
376
+ `${percent * 100}% done receiving ${metadata.filename} from ${peerId}`
377
+ )
378
+ )
379
+ ```
380
+
381
+ Notice that any metadata is sent with progress events so you can show the
382
+ receiving user that there is a transfer in progress with perhaps the name of the
383
+ incoming file.
384
+
385
+ Since a peer can send multiple transmissions in parallel, you can also use
386
+ metadata to differentiate between them, e.g. by sending a unique ID.
387
+
388
+ ### Encryption
389
+
390
+ Once peers are connected to each other all of their communications are
391
+ end-to-end encrypted. During the initial connection / discovery process, peers'
392
+ [SDPs](https://en.wikipedia.org/wiki/Session_Description_Protocol) are sent via
393
+ the chosen peering strategy medium. By default the SDP is encrypted using a key
394
+ derived from your app ID and room ID to prevent plaintext session data from
395
+ appearing in logs. This is fine for most use cases, however a relay strategy
396
+ operator can reverse engineer the key using the room and app IDs. A more secure
397
+ option is to pass a `password` parameter in the app configuration object which
398
+ will be used to derive the encryption key:
399
+
400
+ ```js
401
+ joinRoom({appId: 'kinneret', password: 'MuchoMaa$'}, 'w_a_s_t_e__v_i_p')
402
+ ```
403
+
404
+ This is a shared secret that must be known ahead of time and the password must
405
+ match for all peers in the room for them to be able to connect. An example use
406
+ case might be a private chat room where users learn the password via external
407
+ means.
408
+
409
+ ### React hooks
410
+
411
+ Trystero functions are idempotent so they already work out of the box as React
412
+ hooks.
413
+
414
+ Here's a simple example component where each peer syncs their favorite color to
415
+ everyone else:
416
+
417
+ ```jsx
418
+ import {joinRoom} from 'trystero'
419
+ import {useState} from 'react'
420
+
421
+ const trysteroConfig = {appId: 'thurn-und-taxis'}
422
+
423
+ export default function App({roomId}) {
424
+ const room = joinRoom(trysteroConfig, roomId)
425
+ const [sendColor, getColor] = room.makeAction('color')
426
+ const [myColor, setMyColor] = useState('#c0ffee')
427
+ const [peerColors, setPeerColors] = useState({})
428
+
429
+ // whenever new peers join the room, send my color to them:
430
+ room.onPeerJoin(peer => sendColor(myColor, peer))
431
+
432
+ // listen for peers sending their colors and update the state accordingly:
433
+ getColor((color, peer) =>
434
+ setPeerColors(peerColors => ({...peerColors, [peer]: color}))
435
+ )
436
+
437
+ const updateColor = e => {
438
+ const {value} = e.target
439
+
440
+ // when updating my own color, broadcast it to all peers:
441
+ sendColor(value)
442
+ setMyColor(value)
443
+ }
444
+
445
+ return (
446
+ <>
447
+ <h1>Trystero + React</h1>
448
+
449
+ <h2>My color:</h2>
450
+ <input type="color" value={myColor} onChange={updateColor} />
451
+
452
+ <h2>Peer colors:</h2>
453
+ <ul>
454
+ {Object.entries(peerColors).map(([peerId, color]) => (
455
+ <li key={peerId} style={{backgroundColor: color}}>
456
+ {peerId}: {color}
457
+ </li>
458
+ ))}
459
+ </ul>
460
+ </>
461
+ )
462
+ }
463
+ ```
464
+
465
+ Astute readers may notice the above example is simple and doesn't consider if we
466
+ want to change the component's room ID or unmount it. For those scenarios you
467
+ can use this simple `useRoom()` hook that unsubscribes from room events
468
+ accordingly:
469
+
470
+ ```js
471
+ import {joinRoom} from 'trystero'
472
+ import {useEffect, useRef} from 'react'
473
+
474
+ export const useRoom = (roomConfig, roomId) => {
475
+ const roomRef = useRef(joinRoom(roomConfig, roomId))
476
+ const lastRoomIdRef = useRef(roomId)
477
+
478
+ useEffect(() => {
479
+ if (roomId !== lastRoomIdRef.current) {
480
+ roomRef.current.leave()
481
+ roomRef.current = joinRoom(roomConfig, roomId)
482
+ lastRoomIdRef.current = roomId
483
+ }
484
+
485
+ return () => roomRef.current.leave()
486
+ }, [roomConfig, roomId])
487
+
488
+ return roomRef.current
489
+ }
490
+ ```
491
+
492
+ ### Troubleshooting connection issues
493
+
494
+ WebRTC is powerful but some networks simply don't allow direct P2P connections
495
+ using it. If you find that certain user pairings aren't working in Trystero,
496
+ you're likely encountering an issue at the network provider level. To solve this
497
+ you can configure a TURN server which will act as a proxy layer for peers that
498
+ aren't able to connect directly to one another.
499
+
500
+ 1. If you can, confirm that the issue is specific to particular network
501
+ conditions (e.g. user with ISP X cannot connect to a user with ISP Y). If
502
+ other user pairings are working (like those between two browsers on the same
503
+ machine), this likely confirms that Trystero is working correctly.
504
+ 2. Sign up for a TURN service or host your own. There are various hosted TURN
505
+ services you can find online like
506
+ [Cloudflare](https://developers.cloudflare.com/calls/turn/) (which offers a
507
+ free tier with 1,000 GB traffic per month) or
508
+ [Open Relay](https://www.metered.ca/stun-turn). You can also host an open
509
+ source TURN server like [coturn](https://github.com/coturn/coturn),
510
+ [Pion TURN](https://github.com/pion/turn),
511
+ [Violet](https://github.com/paullouisageneau/violet), or
512
+ [eturnal](https://github.com/processone/eturnal). Keep in mind data will only
513
+ go through the TURN server for peers that can't directly connect and will
514
+ still be end-to-end encrypted.
515
+ 3. Once you have a TURN server, configure Trystero with it like this:
516
+ ```js
517
+ const room = joinRoom(
518
+ {
519
+ // ...your app config
520
+ turnConfig: [
521
+ {
522
+ // single string or list of strings of URLs to access TURN server
523
+ urls: ['turn:your-turn-server.ok:1979'],
524
+ username: 'username',
525
+ credential: 'password'
526
+ }
527
+ ]
528
+ },
529
+ 'roomId'
530
+ )
531
+ ```
532
+
533
+ ### Running server-side (Node, Bun)
534
+
535
+ Trystero works outside browsers too, like in Node or Bun. Why would you want to
536
+ run something that helps you avoid servers on a server? One reason is if you
537
+ want an always-on peer which can be useful for remembering the last state of
538
+ data, broadcasting it to new users. Another reason might be to run peers that
539
+ are lighter weight and don't need a full browser running, like an embedded
540
+ device or Raspberry Pi.
541
+
542
+ Running server-side uses the same syntax as in the browser, but you need to
543
+ import a polyfill for WebRTC support:
544
+
545
+ ```js
546
+ import {joinRoom} from 'trystero'
547
+ import {RTCPeerConnection} from 'werift'
548
+
549
+ const room = joinRoom(
550
+ {appId: 'your-app-id', rtcPolyfill: RTCPeerConnection},
551
+ 'your-room-name'
552
+ )
553
+ ```
554
+
555
+ ### Self-hosted WebSocket relay
556
+
557
+ If you want a tiny relay that you control, use the WebSocket relay package.
558
+ Start the relay on Node or Bun:
559
+
560
+ ```js
561
+ import {createWsRelayServer} from '@trystero-p2p/ws-relay/server'
562
+
563
+ createWsRelayServer({port: 8080})
564
+ ```
565
+
566
+ Then in the browser:
567
+
568
+ ```js
569
+ import {joinRoom} from '@trystero-p2p/ws-relay'
570
+
571
+ const room = joinRoom(
572
+ {
573
+ appId: 'app-id',
574
+ relayConfig: {
575
+ urls: ['wss://localhost:8080']
576
+ }
577
+ },
578
+ 'room-id'
579
+ )
580
+ ```
581
+
582
+ The `relayConfig.urls` config option is required for this strategy because there
583
+ are no public default servers. You can pass multiple.
584
+
585
+ ### Write your own strategy
586
+
587
+ If you want to provide your own signaling backend, you can build a custom
588
+ strategy with `createStrategy`.
589
+
590
+ The example below assumes a WebSocket relay that does simple pub/sub routing:
591
+
592
+ - Client sends
593
+ `{"type": "subscribe" | "unsubscribe" | "publish", "topic", "payload"}`
594
+ - Server broadcasts `{"topic", "payload"}` to subscribers of each topic
595
+
596
+ This is just to show you how it works; if you want a simple self-hosted
597
+ solution, use the `ws-relay` package explained above.
598
+
599
+ ```js
600
+ import {createStrategy, selfId, toJson} from '@trystero-p2p/core'
601
+
602
+ export const joinRoom = createStrategy({
603
+ // Define init as a function that returns a promise of your signaling client.
604
+ // Resolve the promise when your client is ready to send messages.
605
+ // You can also return an array of client promises for redundancy.
606
+ // In this case, the client is a single WebSocket.
607
+ init: config =>
608
+ new Promise((resolve, reject) => {
609
+ const ws = new WebSocket(config.relayConfig.urls[0])
610
+ ws.addEventListener('open', () => resolve(ws), {once: true})
611
+ ws.addEventListener('error', reject, {once: true})
612
+ }),
613
+
614
+ // Subscribe takes 5 arguments:
615
+ // 1. One of the relay clients you defined in init.
616
+ // 2. rootTopic is the channel where peers in a room announce their presence.
617
+ // 3. selfTopic is the current peer's own channel where messages specific to
618
+ // it should flow (i.e. offers and answers from external peers).
619
+ // 4. onMessage is a function that should be called when your relay client
620
+ // gets a message. Call it with the topic, data payload, and a callback
621
+ // function that takes another topic and payload.
622
+ // 5. getOffers is an optional helper for precomputed offers (only needed if
623
+ // the strategy needs to send offers in bulk, like torrents).
624
+ // It should return a cleanup function that unsubscribes
625
+ subscribe: (client, rootTopic, selfTopic, onMessage, _getOffers) => {
626
+ const topics = [rootTopic, selfTopic]
627
+ const onWsMessage = event => {
628
+ const {topic, payload} = JSON.parse(String(event.data))
629
+
630
+ if (!topics.includes(topic)) {
631
+ return
632
+ }
633
+
634
+ onMessage(topic, payload, (peerTopic, signal) =>
635
+ client.send(
636
+ toJson({type: 'publish', topic: peerTopic, payload: signal})
637
+ )
638
+ )
639
+ }
640
+
641
+ client.addEventListener('message', onWsMessage)
642
+ topics.forEach(topic => client.send(toJson({type: 'subscribe', topic})))
643
+
644
+ return () => {
645
+ topics.forEach(topic => client.send(toJson({type: 'unsubscribe', topic})))
646
+ client.removeEventListener('message', onWsMessage)
647
+ }
648
+ },
649
+
650
+ // announce takes a relay client and a rootTopic
651
+ announce: (client, rootTopic) =>
652
+ client.send(
653
+ toJson({
654
+ type: 'publish',
655
+ topic: rootTopic,
656
+ payload: toJson({peerId: selfId})
657
+ })
658
+ )
659
+ })
660
+
661
+ const room = joinRoom(
662
+ {appId: 'my-app-id', relayConfig: {urls: ['wss://my-relay.example']}},
663
+ 'my-room-id'
664
+ )
665
+ ```
666
+
667
+ ### Supabase setup
668
+
669
+ To use the Supabase strategy:
670
+
671
+ 1. Create a [Supabase](https://supabase.com) project or use an existing one
672
+ 2. On the dashboard, go to Project Settings -> API
673
+ 3. Copy the Project URL and set that as the `appId` in the Trystero config, copy
674
+ the `anon public` API key and set it as `relayConfig.supabaseKey` in the
675
+ Trystero config
676
+
677
+ ### Firebase setup
678
+
679
+ If you want to use the Firebase strategy and don't have an existing project:
680
+
681
+ 1. Create a [Firebase](https://firebase.google.com/) project
682
+ 2. Create a new Realtime Database
683
+ 3. Copy the `databaseURL` and use it as the `appId` in your Trystero config
684
+
685
+ <details>
686
+ <summary>
687
+ Optional: configure the database with security rules to limit activity:
688
+ </summary>
689
+
690
+ ```json
691
+ {
692
+ "rules": {
693
+ ".read": false,
694
+ ".write": false,
695
+ "__trystero__": {
696
+ ".read": false,
697
+ ".write": false,
698
+ "$room_id": {
699
+ ".read": true,
700
+ ".write": true
701
+ }
702
+ }
703
+ }
704
+ }
705
+ ```
706
+
707
+ These rules ensure room peer presence is only readable if the room namespace is
708
+ known ahead of time.
709
+
710
+ </details>
711
+
712
+ ## API
713
+
714
+ ### `joinRoom(config, roomId, [callbacks])`
715
+
716
+ Adds local user to room whereby other peers in the same namespace will open
717
+ communication channels and send events. Calling `joinRoom()` multiple times with
718
+ the same namespace will return the same room instance.
719
+
720
+ - `config` - Configuration object containing the following keys:
721
+ - `appId` - **(required)** A unique string identifying your app. When using
722
+ Supabase, this should be set to your project URL (see
723
+ [Supabase setup instructions](#supabase-setup)). If using Firebase, this
724
+ should be the `databaseURL` from your Firebase config (also see
725
+ `relayConfig.firebaseApp` below for an alternative way of configuring the
726
+ Firebase strategy).
727
+
728
+ - `password` - **(optional)** A string to encrypt session descriptions via
729
+ AES-GCM as they are passed through the peering medium. If not set, session
730
+ descriptions will be encrypted with a key derived from the app ID and room
731
+ name. A custom password must match between any peers in the room for them to
732
+ connect. See [encryption](#encryption) for more details.
733
+
734
+ - `relayConfig` - **(optional unless required by your chosen strategy)**
735
+ Object containing strategy-specific relay settings:
736
+ - `urls` - **(optional for 🌊 BitTorrent, 🐦 Nostr, πŸ“‘ MQTT; required for πŸ”Œ
737
+ WebSocket relay)** Custom list of URLs for the strategy to use to
738
+ bootstrap P2P connections. These would be BitTorrent trackers, Nostr
739
+ relays, MQTT brokers, and WebSocket relays, respectively. They must
740
+ support secure WebSocket connections.
741
+
742
+ - `redundancy` - **(optional, 🌊 BitTorrent, 🐦 Nostr, πŸ“‘ MQTT only)**
743
+ Integer specifying how many default relay endpoints to connect to
744
+ simultaneously. Passing a `urls` option will cause this option to be
745
+ ignored as the entire list will be used.
746
+
747
+ - `manualReconnection` - **(optional, 🐦 Nostr and 🌊 BitTorrent only)**
748
+ Boolean (default: `false`) that when set to `true` disables automatically
749
+ pausing and resuming reconnection attempts when the browser goes offline
750
+ and comes back online. This is useful if you want to manage this behavior
751
+ yourself.
752
+
753
+ - `supabaseKey` - **(required, ⚑️ Supabase only)** Your Supabase project's
754
+ `anon public` API key.
755
+
756
+ - `firebaseApp` - **(optional, πŸ”₯ Firebase only)** You can pass an already
757
+ initialized Firebase app instance instead of an `appId`. Normally Trystero
758
+ will initialize a Firebase app based on the `appId` but this will fail if
759
+ you've already initialized it for use elsewhere.
760
+
761
+ - `firebasePath` - **(optional, πŸ”₯ Firebase only)** String specifying path
762
+ where Trystero writes its matchmaking data in your database
763
+ (`'__trystero__'` by default). Changing this is useful if you want to run
764
+ multiple apps using the same database and don't want to worry about
765
+ namespace collisions.
766
+
767
+ - `rtcConfig` - **(optional)** Specifies a custom
768
+ [`RTCConfiguration`](https://developer.mozilla.org/en-US/docs/Web/API/RTCConfiguration)
769
+ for all peer connections.
770
+
771
+ - `trickleIce` - **(optional)** Boolean controlling whether ICE candidates are
772
+ sent incrementally (`true`) or bundled with SDP (`false`). Default is
773
+ strategy-dependent: `true` for most strategies, `false` for BitTorrent and
774
+ IPFS unless explicitly set.
775
+
776
+ - `turnConfig` - **(optional)** Specifies a custom list of TURN servers to use
777
+ (see
778
+ [Troubleshooting connection issues](#troubleshooting-connection-issues)).
779
+ Each item in the list should correspond to an
780
+ [ICE server config object](https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/RTCPeerConnection#iceservers).
781
+ When passing a TURN config like this, Trystero's default STUN servers will
782
+ also be used. To override this and use both custom STUN and TURN servers,
783
+ instead pass the config via the above `rtcConfig.iceServers` option as a
784
+ list of both STUN/TURN servers β€” this won't inherit Trystero's defaults.
785
+
786
+ - `rtcPolyfill` - **(optional)** Use this to pass a custom
787
+ [`RTCPeerConnection`](https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection/RTCPeerConnection)-compatible
788
+ constructor. This is useful for running outside of a browser, such as in
789
+ Node (still experimental).
790
+
791
+ - `roomId` - A string to namespace peers and events within a room.
792
+
793
+ - `callbacks` - **(optional)** Callback config object containing:
794
+ - `onJoinError(details)` - Called when room join fails due to an incorrect
795
+ password or when handshake admission fails (including timeout). `details` is
796
+ an object containing `appId`, `roomId`, `peerId`, and `error` describing the
797
+ failure.
798
+
799
+ - `onPeerHandshake(peerId, send, receive, isInitiator)` - Async predicate that
800
+ runs after the transport connects but before the peer becomes active.
801
+ Return/resolve to accept the peer, throw/reject to deny the peer.
802
+ - `peerId` - ID of the pending peer.
803
+ - `send(data, [metadata])` - Sends handshake payloads to the pending peer.
804
+ - `receive()` - Resolves to the next handshake message from the peer as
805
+ `{data, metadata}`.
806
+ - `isInitiator` - Deterministic role flag for avoiding protocol deadlocks.
807
+
808
+ - `handshakeTimeoutMs` - Timeout for pending handshakes in milliseconds
809
+ (`10000` by default). If exceeded, the peer is denied and `onJoinError` is
810
+ called.
811
+
812
+ During handshake, the peer remains pending and is not included in `getPeers()`
813
+ and does not trigger Trystero API events (`onPeerJoin`, action receivers,
814
+ stream/track callbacks). Non-handshake data received while pending is dropped.
815
+
816
+ Minimal handshake example:
817
+
818
+ ```js
819
+ import {joinRoom} from 'trystero'
820
+
821
+ const room = joinRoom({appId: 'my-app'}, 'secure-room', {
822
+ onPeerHandshake: async (_, send, receive, isInitiator) => {
823
+ if (isInitiator) {
824
+ await send({challenge: 'prove-you-know-the-secret'})
825
+ const {data} = await receive()
826
+
827
+ if (data?.response !== 'shared-secret') {
828
+ throw new Error('handshake rejected')
829
+ }
830
+ } else {
831
+ const {data} = await receive()
832
+
833
+ if (data?.challenge !== 'prove-you-know-the-secret') {
834
+ throw new Error('handshake rejected')
835
+ }
836
+
837
+ await send({response: 'shared-secret'})
838
+ }
839
+ }
840
+ })
841
+ ```
842
+
843
+ Returns an object with the following methods:
844
+
845
+ - ### `leave()`
846
+
847
+ Remove local user from room and unsubscribe from room events.
848
+
849
+ - ### `getPeers()`
850
+
851
+ Returns a map of
852
+ [`RTCPeerConnection`](https://developer.mozilla.org/en-US/docs/Web/API/RTCPeerConnection)s
853
+ for the peers present in room (not including the local user). The keys of this
854
+ object are the respective peers' IDs.
855
+
856
+ - ### `addStream(stream, [targetPeers], [metadata])`
857
+
858
+ Broadcasts media stream to other peers.
859
+ - `stream` - A `MediaStream` with audio and/or video to send to peers in the
860
+ room.
861
+
862
+ - `targetPeers` - **(optional)** If specified, the stream is sent only to the
863
+ target peer ID (string) or list of peer IDs (array).
864
+
865
+ - `metadata` - **(optional)** Additional metadata (any serializable type) to
866
+ be sent with the stream. This is useful when sending multiple streams so
867
+ recipients know which is which (e.g. a webcam versus a screen capture). If
868
+ you want to broadcast a stream to all peers in the room with a metadata
869
+ argument, pass `null` as the second argument.
870
+
871
+ - ### `removeStream(stream, [targetPeers])`
872
+
873
+ Stops sending previously sent media stream to other peers.
874
+ - `stream` - A previously sent `MediaStream` to stop sending.
875
+
876
+ - `targetPeers` - **(optional)** If specified, the stream is removed only from
877
+ the target peer ID (string) or list of peer IDs (array).
878
+
879
+ - ### `addTrack(track, stream, [targetPeers], [metadata])`
880
+
881
+ Adds a new media track to a stream.
882
+ - `track` - A `MediaStreamTrack` to add to an existing stream.
883
+
884
+ - `stream` - The target `MediaStream` to attach the new track to.
885
+
886
+ - `targetPeers` - **(optional)** If specified, the track is sent only to the
887
+ target peer ID (string) or list of peer IDs (array).
888
+
889
+ - `metadata` - **(optional)** Additional metadata (any serializable type) to
890
+ be sent with the track. See `metadata` notes for `addStream()` above for
891
+ more details.
892
+
893
+ - ### `removeTrack(track, [targetPeers])`
894
+
895
+ Removes a media track.
896
+ - `track` - The `MediaStreamTrack` to remove.
897
+
898
+ - `targetPeers` - **(optional)** If specified, the track is removed only from
899
+ the target peer ID (string) or list of peer IDs (array).
900
+
901
+ - ### `replaceTrack(oldTrack, newTrack, [targetPeers], [metadata])`
902
+
903
+ Replaces a media track with a new one.
904
+ - `oldTrack` - The `MediaStreamTrack` to remove.
905
+
906
+ - `newTrack` - A `MediaStreamTrack` to attach.
907
+
908
+ - `targetPeers` - **(optional)** If specified, the track is replaced only for
909
+ the target peer ID (string) or list of peer IDs (array).
910
+
911
+ - `metadata` - **(optional)** Additional metadata (any serializable type) to
912
+ be sent with the replacement track.
913
+
914
+ - ### `onPeerJoin(callback)`
915
+
916
+ Registers a callback function that will be called when a peer joins the room.
917
+ If called more than once, only the latest callback registered is ever called.
918
+ Existing active peers are immediately replayed to a newly registered callback.
919
+ - `callback(peerId)` - Function to run whenever a peer joins, called with the
920
+ peer's ID.
921
+
922
+ Example:
923
+
924
+ ```js
925
+ onPeerJoin(peerId => console.log(`${peerId} joined`))
926
+ ```
927
+
928
+ - ### `onPeerLeave(callback)`
929
+
930
+ Registers a callback function that will be called when a peer leaves the room.
931
+ If called more than once, only the latest callback registered is ever called.
932
+ - `callback(peerId)` - Function to run whenever a peer leaves, called with the
933
+ peer's ID.
934
+
935
+ Example:
936
+
937
+ ```js
938
+ onPeerLeave(peerId => console.log(`${peerId} left`))
939
+ ```
940
+
941
+ - ### `onPeerStream(callback)`
942
+
943
+ Registers a callback function that will be called when a peer sends a media
944
+ stream. If called more than once, only the latest callback registered is ever
945
+ called.
946
+ - `callback(stream, peerId, metadata)` - Function to run whenever a peer sends
947
+ a media stream, called with the the peer's stream, ID, and optional metadata
948
+ (see `addStream()` above for details).
949
+
950
+ Example:
951
+
952
+ ```js
953
+ onPeerStream((stream, peerId) =>
954
+ console.log(`got stream from ${peerId}`, stream)
955
+ )
956
+ ```
957
+
958
+ - ### `onPeerTrack(callback)`
959
+
960
+ Registers a callback function that will be called when a peer sends a media
961
+ track. If called more than once, only the latest callback registered is ever
962
+ called.
963
+ - `callback(track, stream, peerId, metadata)` - Function to run whenever a
964
+ peer sends a media track, called with the the peer's track, attached stream,
965
+ ID, and optional metadata (see `addTrack()` above for details).
966
+
967
+ Example:
968
+
969
+ ```js
970
+ onPeerTrack((track, stream, peerId) =>
971
+ console.log(`got track from ${peerId}`, track)
972
+ )
973
+ ```
974
+
975
+ - ### `makeAction(actionId)`
976
+
977
+ Listen for and send custom data actions.
978
+ - `actionId` - A string to register this action consistently among all peers.
979
+
980
+ Returns an array of three functions:
981
+ 1. #### Sender
982
+ - Sends data to peers and returns a promise that resolves when all target
983
+ peers are finished sending locally.
984
+
985
+ - `(data, [targetPeers], [metadata], [onProgress])`
986
+ - `data` - Any value to send (primitive, object, binary). Serialization
987
+ and chunking is handled automatically. Binary data (e.g. `Blob`,
988
+ `TypedArray`) is received by other peer as an agnostic `ArrayBuffer`.
989
+
990
+ - `targetPeers` - **(optional)** Either a peer ID (string), an array of
991
+ peer IDs, or `null` (indicating to send to all peers in the room).
992
+
993
+ - `metadata` - **(optional)** If the data is binary, you can send an
994
+ optional metadata object describing it (see
995
+ [Binary metadata](#binary-metadata)).
996
+
997
+ - `onProgress` - **(optional)** A callback function that will be called
998
+ as every chunk for every peer is transmitted. The function will be
999
+ called with a value between 0 and 1 and a peer ID. See
1000
+ [Progress updates](#progress-updates) for an example.
1001
+
1002
+ 2. #### Receiver
1003
+ - Registers a callback function that runs when data for this action is
1004
+ received from other peers.
1005
+
1006
+ - `(data, peerId, metadata)`
1007
+ - `data` - The value transmitted by the sending peer. Deserialization is
1008
+ handled automatically, i.e. a number will be received as a number, an
1009
+ object as an object, etc.
1010
+
1011
+ - `peerId` - The ID string of the sending peer.
1012
+
1013
+ - `metadata` - **(optional)** Optional metadata object supplied by the
1014
+ sender if `data` is binary, e.g. a filename.
1015
+
1016
+ 3. #### Progress handler
1017
+ - Registers a callback function that runs when partial data is received
1018
+ from peers. You can use this for tracking large binary transfers. See
1019
+ [Progress updates](#progress-updates) for an example.
1020
+
1021
+ - `(percent, peerId, metadata)`
1022
+ - `percent` - A number between 0 and 1 indicating the percentage complete
1023
+ of the transfer.
1024
+
1025
+ - `peerId` - The ID string of the sending peer.
1026
+
1027
+ - `metadata` - **(optional)** Optional metadata object supplied by the
1028
+ sender.
1029
+
1030
+ Example:
1031
+
1032
+ ```js
1033
+ const [sendCursor, getCursor] = room.makeAction('cursormove')
1034
+
1035
+ window.addEventListener('mousemove', e => sendCursor([e.clientX, e.clientY]))
1036
+
1037
+ getCursor(([x, y], peerId) => {
1038
+ const peerCursor = cursorMap[peerId]
1039
+ peerCursor.style.left = x + 'px'
1040
+ peerCursor.style.top = y + 'px'
1041
+ })
1042
+ ```
1043
+
1044
+ - ### `ping(peerId)`
1045
+
1046
+ Takes a peer ID and returns a promise that resolves to the milliseconds the
1047
+ round-trip to that peer took. Use this for measuring latency.
1048
+ - `peerId` - Peer ID string of the target peer.
1049
+
1050
+ Example:
1051
+
1052
+ ```js
1053
+ // log round-trip time every 2 seconds
1054
+ room.onPeerJoin(peerId =>
1055
+ setInterval(
1056
+ async () => console.log(`took ${await room.ping(peerId)}ms`),
1057
+ 2000
1058
+ )
1059
+ )
1060
+ ```
1061
+
1062
+ ### `selfId`
1063
+
1064
+ A unique ID string other peers will know the local user as globally across
1065
+ rooms.
1066
+
1067
+ ### `getRelaySockets()`
1068
+
1069
+ **(🌊 BitTorrent, 🐦 Nostr, πŸ“‘ MQTT, πŸ”Œ WebSocket relay only)** Returns an
1070
+ object of relay URL keys mapped to their WebSocket connections. This can be
1071
+ useful for determining the state of the user's connection to the relays and
1072
+ handling any connection failures.
1073
+
1074
+ Example:
1075
+
1076
+ ```js
1077
+ import {getRelaySockets} from '@trystero-p2p/torrent'
1078
+
1079
+ console.log(getRelaySockets())
1080
+ // => Object {
1081
+ // "wss://tracker.webtorrent.dev": WebSocket,
1082
+ // "wss://tracker.openwebtorrent.com": WebSocket
1083
+ // }
1084
+ ```
1085
+
1086
+ ### `pauseRelayReconnection()`
1087
+
1088
+ **(🐦 Nostr, 🌊 BitTorrent only)** Normally Trystero will try to automatically
1089
+ reconnect to relay sockets unless `relayConfig.manualReconnection: true` is set
1090
+ in the room config. Calling this function stops relay reconnection attempts
1091
+ until `resumeRelayReconnection()` is called.
1092
+
1093
+ ### `resumeRelayReconnection()`
1094
+
1095
+ **(🐦 Nostr, 🌊 BitTorrent only)** Allows relay reconnection attempts to resume.
1096
+ (See `pauseRelayReconnection()` above).
1097
+
1098
+ ## Which strategy should I choose?
1099
+
1100
+ By default Trystero uses the Nostr network which is highly decentralized with
1101
+ hundreds of active relays running. This is a good choice if you're interested in
1102
+ decentralization and high redundancy. The other decentralized strategies are
1103
+ recommended in the order of MQTT, BitTorrent, and IPFS, based on robustness.
1104
+ These networks have far less relay redundancy than Nostr, but you might prefer
1105
+ them for other reasons. You can of course host your own relay server for any of
1106
+ these strategies.
1107
+
1108
+ For a middleground between using public relays and self-hosting, the built-in
1109
+ Supabase and Firebase strategies are a good option.
1110
+
1111
+ ---
1112
+
1113
+ Trystero by [Dan Motzenbecker](https://oxism.com)
package/dist/index.d.mts CHANGED
@@ -1,8 +1,8 @@
1
- import { BaseRoomConfig, JoinRoom, RelayConfig, pauseRelayReconnection, resumeRelayReconnection, selfId } from "@trystero-p2p/core";
1
+ import { JoinRoom, JoinRoomConfig, pauseRelayReconnection, resumeRelayReconnection, selfId } from "@trystero-p2p/core";
2
2
  export * from "@trystero-p2p/core";
3
3
 
4
4
  //#region src/index.d.ts
5
- type TorrentRoomConfig = BaseRoomConfig & RelayConfig;
5
+ type TorrentRoomConfig = JoinRoomConfig;
6
6
  declare const joinRoom: JoinRoom<TorrentRoomConfig>;
7
7
  declare const getRelaySockets: any;
8
8
  declare const defaultRelayUrls: string[];
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../src/index.ts"],"mappings":";;;;KA+CY,iBAAA,GAAoB,cAAA,GAAiB,WAAA;AAAA,cAoVpC,QAAA,EAAU,QAAA,CAAS,iBAAA;AAAA,cAcnB,eAAA;AAAA,cAIA,gBAAA"}
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/index.ts"],"mappings":";;;;KA8CY,iBAAA,GAAoB,cAAA;AAAA,cA6VnB,QAAA,EAAU,QAAA,CAAS,iBAAA;AAAA,cAcnB,eAAA;AAAA,cAIA,gBAAA"}
package/dist/index.mjs CHANGED
@@ -135,18 +135,21 @@ const joinRoomStrategy = createStrategy({
135
135
  roomSubscriberCounts[rootTopic] = (roomSubscriberCounts[rootTopic] ?? 0) + 1;
136
136
  const topicHandler = (data) => {
137
137
  if (data.offer && data.peer_id && data.offer_id) onMessage(rootTopic, {
138
- offer: data.offer,
138
+ offer: data.offer.sdp,
139
139
  peerId: data.peer_id,
140
140
  hasOutgoingOffer: keys(getRoomOutstandingOffers(rootTopic)).length > 0
141
141
  }, (_, signal) => void send(client, rootTopic, {
142
- answer: fromJson(signal)["answer"],
142
+ answer: {
143
+ type: "answer",
144
+ sdp: fromJson(signal).answer
145
+ },
143
146
  offer_id: data.offer_id,
144
147
  to_peer_id: data.peer_id
145
148
  }));
146
149
  else if (data.answer && data.offer_id && data.peer_id) {
147
150
  const offer = claimOutstandingOffer(rootTopic, data.offer_id);
148
151
  if (offer) onMessage(rootTopic, {
149
- answer: data.answer,
152
+ answer: data.answer.sdp,
150
153
  peerId: data.peer_id,
151
154
  peer: offer.peer
152
155
  }, () => {});
@@ -159,7 +162,10 @@ const joinRoomStrategy = createStrategy({
159
162
  numwant: offerPoolSize,
160
163
  offers: entries(await ensureOutstandingOffers(rootTopic, getOffers)).map(([id, { offer }]) => ({
161
164
  offer_id: id,
162
- offer
165
+ offer: {
166
+ type: "offer",
167
+ sdp: offer
168
+ }
163
169
  }))
164
170
  });
165
171
  };
@@ -1 +1 @@
1
- {"version":3,"file":"index.mjs","names":[],"sources":["../src/index.ts"],"sourcesContent":["import {\n createRelayManager,\n createStrategy,\n entries,\n fromJson,\n genId,\n getRelays,\n keys,\n libName,\n makeSocket,\n pauseRelayReconnection,\n resumeRelayReconnection,\n selfId,\n sha1,\n toJson,\n type BaseRoomConfig,\n type JoinRoom,\n type OfferRecord,\n type RelayConfig,\n type SocketClient\n} from '@trystero-p2p/core'\n\nconst relayManager = createRelayManager<SocketClient>(client => client.socket)\nconst topicToInfoHash: Record<string, string> = {}\nconst infoHashToTopic: Record<string, string> = {}\nconst announceIntervals = relayManager.scoped<ReturnType<typeof setInterval>>()\nconst announceFns = relayManager.scoped<() => void | Promise<void>>()\nconst subscriptionTokens = relayManager.scoped<symbol>()\nconst trackerAnnounceMs: Record<string, number> = {}\nconst handledSignals: Record<string, number> = {}\nconst msgHandlers = relayManager.scoped<(data: TrackerMessage) => void>()\nconst roomOutstandingOffers: Record<\n string,\n Record<string, OfferRecord & {createdAt: number}>\n> = {}\nconst roomOfferGenerationPromises: Record<string, Promise<void> | undefined> =\n {}\nconst roomSubscriberCounts: Record<string, number> = {}\nconst trackerAction = 'announce'\nconst hashLimit = 20\nconst offerPoolSize = 3\nconst defaultAnnounceMs = 10_000\nconst maxAnnounceMs = 20_000\nconst offerRetentionMs = 120_000\nconst signalDedupeWindowMs = 4_000\nconst defaultRedundancy = 3\n\nexport type TorrentRoomConfig = BaseRoomConfig & RelayConfig\n\ntype TrackerMessage = {\n offer?: unknown\n answer?: unknown\n offer_id?: string\n peer_id?: string\n info_hash?: string\n interval?: number\n ['failure reason']?: string\n ['warning message']?: string\n}\n\nconst getInfoHash = async (topic: string): Promise<string> => {\n if (topicToInfoHash[topic]) {\n return topicToInfoHash[topic]\n }\n\n const hash = (await sha1(topic)).slice(0, hashLimit)\n topicToInfoHash[topic] = hash\n infoHashToTopic[hash] = topic\n\n return hash\n}\n\nconst send = async (\n client: SocketClient,\n topic: string,\n payload: Record<string, unknown>\n): Promise<void> =>\n client.send(\n toJson({\n action: trackerAction,\n info_hash: await getInfoHash(topic),\n peer_id: selfId,\n ...payload\n })\n )\n\nconst warn = (url: string, msg: string, didFail = false): void =>\n console.warn(\n `${libName}: torrent tracker ${didFail ? 'failure' : 'warning'} from ${url} - ${msg}`\n )\n\nconst getRoomOutstandingOffers = (\n rootTopic: string\n): Record<string, OfferRecord & {createdAt: number}> =>\n (roomOutstandingOffers[rootTopic] ??= {})\n\nconst deleteRoomOfferBookkeeping = (rootTopic: string): void => {\n delete roomOutstandingOffers[rootTopic]\n delete roomOfferGenerationPromises[rootTopic]\n}\n\nconst claimOutstandingOffer = (\n rootTopic: string,\n offerId: string\n): OfferRecord | undefined => {\n const outstandingOffers = roomOutstandingOffers[rootTopic]\n const offer = outstandingOffers?.[offerId]\n\n if (!offer) {\n return\n }\n\n delete outstandingOffers[offerId]\n offer.claim?.()\n\n if (!keys(outstandingOffers).length && !roomSubscriberCounts[rootTopic]) {\n deleteRoomOfferBookkeeping(rootTopic)\n }\n\n return offer\n}\n\nconst reclaimOutstandingOffer = (rootTopic: string, offerId: string): void => {\n const outstandingOffers = roomOutstandingOffers[rootTopic]\n const offer = outstandingOffers?.[offerId]\n\n if (!offer) {\n return\n }\n\n delete outstandingOffers[offerId]\n offer.reclaim?.()\n\n if (!keys(outstandingOffers).length && !roomSubscriberCounts[rootTopic]) {\n deleteRoomOfferBookkeeping(rootTopic)\n }\n}\n\nconst reclaimAllOutstandingOffers = (rootTopic: string): void => {\n keys(getRoomOutstandingOffers(rootTopic)).forEach(offerId =>\n reclaimOutstandingOffer(rootTopic, offerId)\n )\n deleteRoomOfferBookkeeping(rootTopic)\n}\n\nconst pruneOutstandingOffers = (rootTopic: string): void => {\n const now = Date.now()\n\n entries(getRoomOutstandingOffers(rootTopic)).forEach(([offerId, offer]) => {\n if (now - offer.createdAt > offerRetentionMs) {\n reclaimOutstandingOffer(rootTopic, offerId)\n }\n })\n}\n\nconst ensureOutstandingOffers = async (\n rootTopic: string,\n getOffers: (n: number) => Promise<OfferRecord[]>\n): Promise<Record<string, OfferRecord & {createdAt: number}>> => {\n while (roomOfferGenerationPromises[rootTopic]) {\n await roomOfferGenerationPromises[rootTopic]\n }\n\n const nextPromise = (async () => {\n pruneOutstandingOffers(rootTopic)\n\n const outstandingOffers = getRoomOutstandingOffers(rootTopic)\n const outstandingCount = keys(outstandingOffers).length\n const missingOffers = Math.max(0, offerPoolSize - outstandingCount)\n\n if (missingOffers > 0) {\n ;(await getOffers(missingOffers)).forEach(peerAndOffer => {\n outstandingOffers[genId(hashLimit)] = {\n ...peerAndOffer,\n createdAt: Date.now()\n }\n })\n }\n })().finally(() => {\n if (roomOfferGenerationPromises[rootTopic] === nextPromise) {\n delete roomOfferGenerationPromises[rootTopic]\n }\n })\n\n roomOfferGenerationPromises[rootTopic] = nextPromise\n await nextPromise\n\n return getRoomOutstandingOffers(rootTopic)\n}\n\nconst joinRoomStrategy: JoinRoom<TorrentRoomConfig> = createStrategy({\n init: config =>\n getRelays(config, defaultRelayUrls, defaultRedundancy).map(rawUrl => {\n const client = relayManager.register(\n rawUrl,\n makeSocket(rawUrl, rawData => {\n const data = fromJson<TrackerMessage>(rawData)\n const errMsg = data['failure reason']\n const warnMsg = data['warning message']\n const {interval} = data\n const topic = data.info_hash\n ? infoHashToTopic[data.info_hash]\n : undefined\n\n if (errMsg) {\n warn(client.url, errMsg, true)\n return\n }\n\n if (warnMsg) {\n warn(client.url, warnMsg)\n }\n\n if (\n interval &&\n interval * 1000 >\n (trackerAnnounceMs[client.url] ?? defaultAnnounceMs) &&\n topic &&\n announceFns.forKey(rawUrl)[topic]\n ) {\n const nextInterval = Math.min(interval * 1000, maxAnnounceMs)\n const relayIntervals = announceIntervals.forKey(rawUrl)\n const relayFns = announceFns.forKey(rawUrl)\n\n if (relayIntervals[topic]) {\n clearInterval(relayIntervals[topic])\n }\n trackerAnnounceMs[client.url] = nextInterval\n const relayFn = relayFns[topic]\n\n if (relayFn) {\n relayIntervals[topic] = setInterval(() => {\n void relayFn()\n }, nextInterval)\n }\n }\n\n if ((data.offer || data.answer) && topic && data.offer_id) {\n if (data.peer_id === selfId) {\n return\n }\n\n const signalType = data.offer ? 'offer' : 'answer'\n const signalKey = `${topic}:${signalType}:${data.offer_id}:${data.peer_id ?? ''}`\n const nowMs = Date.now()\n const lastHandledMs = handledSignals[signalKey]\n\n if (\n typeof lastHandledMs === 'number' &&\n nowMs - lastHandledMs < signalDedupeWindowMs\n ) {\n return\n }\n\n handledSignals[signalKey] = nowMs\n\n entries(handledSignals).forEach(([key, handledAtMs]) => {\n if (nowMs - handledAtMs > signalDedupeWindowMs * 6) {\n delete handledSignals[key]\n }\n })\n\n msgHandlers.forKey(rawUrl)[topic]?.(data)\n }\n })\n )\n\n return client.ready\n }),\n\n subscribe: (client, rootTopic, _, onMessage, getOffers) => {\n const handlers = msgHandlers.forRelay(client)\n const relayFns = announceFns.forRelay(client)\n const relayIntervals = announceIntervals.forRelay(client)\n const activeTokens = subscriptionTokens.forRelay(client)\n const subscriptionToken = Symbol(rootTopic)\n\n activeTokens[rootTopic] = subscriptionToken\n roomSubscriberCounts[rootTopic] = (roomSubscriberCounts[rootTopic] ?? 0) + 1\n\n const topicHandler = (data: TrackerMessage): void => {\n if (data.offer && data.peer_id && data.offer_id) {\n void onMessage(\n rootTopic,\n {\n offer: data.offer,\n peerId: data.peer_id,\n hasOutgoingOffer:\n keys(getRoomOutstandingOffers(rootTopic)).length > 0\n },\n (_, signal) =>\n void send(client, rootTopic, {\n answer: fromJson<Record<string, unknown>>(signal)['answer'],\n offer_id: data.offer_id,\n to_peer_id: data.peer_id\n })\n )\n } else if (data.answer && data.offer_id && data.peer_id) {\n const offer = claimOutstandingOffer(rootTopic, data.offer_id)\n\n if (offer) {\n void onMessage(\n rootTopic,\n {\n answer: data.answer,\n peerId: data.peer_id,\n peer: offer.peer\n },\n () => {}\n )\n }\n }\n }\n\n handlers[rootTopic] = topicHandler\n\n const announce = async (): Promise<void> => {\n if (activeTokens[rootTopic] !== subscriptionToken) {\n return\n }\n\n const outstandingOffers = await ensureOutstandingOffers(\n rootTopic,\n getOffers\n )\n const offers = entries(outstandingOffers).map(([id, {offer}]) => ({\n offer_id: id,\n offer\n }))\n\n void send(client, rootTopic, {\n numwant: offerPoolSize,\n offers\n })\n }\n\n trackerAnnounceMs[client.url] = defaultAnnounceMs\n relayFns[rootTopic] = announce\n relayIntervals[rootTopic] = setInterval(\n announce,\n trackerAnnounceMs[client.url]\n )\n void announce()\n\n return () => {\n roomSubscriberCounts[rootTopic] = Math.max(\n 0,\n (roomSubscriberCounts[rootTopic] ?? 1) - 1\n )\n\n if (!roomSubscriberCounts[rootTopic]) {\n delete roomSubscriberCounts[rootTopic]\n }\n\n if (activeTokens[rootTopic] !== subscriptionToken) {\n if (!roomSubscriberCounts[rootTopic]) {\n reclaimAllOutstandingOffers(rootTopic)\n }\n\n return\n }\n\n const interval = relayIntervals[rootTopic]\n if (interval) {\n clearInterval(interval)\n delete relayIntervals[rootTopic]\n }\n\n if (handlers[rootTopic] === topicHandler) {\n delete handlers[rootTopic]\n }\n\n if (relayFns[rootTopic] === announce) {\n delete relayFns[rootTopic]\n }\n\n delete activeTokens[rootTopic]\n\n if (!roomSubscriberCounts[rootTopic]) {\n reclaimAllOutstandingOffers(rootTopic)\n }\n }\n },\n\n announce: client => trackerAnnounceMs[client.url]\n})\n\nexport const joinRoom: JoinRoom<TorrentRoomConfig> = (\n config,\n roomId,\n callbacks\n) =>\n joinRoomStrategy(\n {\n ...config,\n trickleIce: config.trickleIce ?? false\n },\n roomId,\n callbacks\n )\n\nexport const getRelaySockets = relayManager.getSockets\n\nexport {pauseRelayReconnection, resumeRelayReconnection, selfId}\n\nexport const defaultRelayUrls = [\n 'tracker.webtorrent.dev',\n 'tracker.openwebtorrent.com',\n 'tracker.btorrent.xyz',\n 'tracker.files.fm:7073/announce'\n].map(url => 'wss://' + url)\n\nexport type * from '@trystero-p2p/core'\n"],"mappings":"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"}
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/index.ts"],"sourcesContent":["import {\n createRelayManager,\n createStrategy,\n entries,\n fromJson,\n genId,\n getRelays,\n keys,\n libName,\n makeSocket,\n pauseRelayReconnection,\n resumeRelayReconnection,\n selfId,\n sha1,\n toJson,\n type JoinRoom,\n type JoinRoomConfig,\n type OfferRecord,\n type SocketClient\n} from '@trystero-p2p/core'\n\nconst relayManager = createRelayManager<SocketClient>(client => client.socket)\nconst topicToInfoHash: Record<string, string> = {}\nconst infoHashToTopic: Record<string, string> = {}\nconst announceIntervals = relayManager.scoped<ReturnType<typeof setInterval>>()\nconst announceFns = relayManager.scoped<() => void | Promise<void>>()\nconst subscriptionTokens = relayManager.scoped<symbol>()\nconst trackerAnnounceMs: Record<string, number> = {}\nconst handledSignals: Record<string, number> = {}\nconst msgHandlers = relayManager.scoped<(data: TrackerMessage) => void>()\nconst roomOutstandingOffers: Record<\n string,\n Record<string, OfferRecord & {createdAt: number}>\n> = {}\nconst roomOfferGenerationPromises: Record<string, Promise<void> | undefined> =\n {}\nconst roomSubscriberCounts: Record<string, number> = {}\nconst trackerAction = 'announce'\nconst hashLimit = 20\nconst offerPoolSize = 3\nconst defaultAnnounceMs = 10_000\nconst maxAnnounceMs = 20_000\nconst offerRetentionMs = 120_000\nconst signalDedupeWindowMs = 4_000\nconst defaultRedundancy = 3\n\nexport type TorrentRoomConfig = JoinRoomConfig\n\ntype TrackerMessage = {\n offer?: {\n type: 'offer'\n sdp: string\n }\n answer?: {\n type: 'answer'\n sdp: string\n }\n offer_id?: string\n peer_id?: string\n info_hash?: string\n interval?: number\n ['failure reason']?: string\n ['warning message']?: string\n}\n\nconst getInfoHash = async (topic: string): Promise<string> => {\n if (topicToInfoHash[topic]) {\n return topicToInfoHash[topic]\n }\n\n const hash = (await sha1(topic)).slice(0, hashLimit)\n topicToInfoHash[topic] = hash\n infoHashToTopic[hash] = topic\n\n return hash\n}\n\nconst send = async (\n client: SocketClient,\n topic: string,\n payload: Record<string, unknown>\n): Promise<void> =>\n client.send(\n toJson({\n action: trackerAction,\n info_hash: await getInfoHash(topic),\n peer_id: selfId,\n ...payload\n })\n )\n\nconst warn = (url: string, msg: string, didFail = false): void =>\n console.warn(\n `${libName}: torrent tracker ${didFail ? 'failure' : 'warning'} from ${url} - ${msg}`\n )\n\nconst getRoomOutstandingOffers = (\n rootTopic: string\n): Record<string, OfferRecord & {createdAt: number}> =>\n (roomOutstandingOffers[rootTopic] ??= {})\n\nconst deleteRoomOfferBookkeeping = (rootTopic: string): void => {\n delete roomOutstandingOffers[rootTopic]\n delete roomOfferGenerationPromises[rootTopic]\n}\n\nconst claimOutstandingOffer = (\n rootTopic: string,\n offerId: string\n): OfferRecord | undefined => {\n const outstandingOffers = roomOutstandingOffers[rootTopic]\n const offer = outstandingOffers?.[offerId]\n\n if (!offer) {\n return\n }\n\n delete outstandingOffers[offerId]\n offer.claim?.()\n\n if (!keys(outstandingOffers).length && !roomSubscriberCounts[rootTopic]) {\n deleteRoomOfferBookkeeping(rootTopic)\n }\n\n return offer\n}\n\nconst reclaimOutstandingOffer = (rootTopic: string, offerId: string): void => {\n const outstandingOffers = roomOutstandingOffers[rootTopic]\n const offer = outstandingOffers?.[offerId]\n\n if (!offer) {\n return\n }\n\n delete outstandingOffers[offerId]\n offer.reclaim?.()\n\n if (!keys(outstandingOffers).length && !roomSubscriberCounts[rootTopic]) {\n deleteRoomOfferBookkeeping(rootTopic)\n }\n}\n\nconst reclaimAllOutstandingOffers = (rootTopic: string): void => {\n keys(getRoomOutstandingOffers(rootTopic)).forEach(offerId =>\n reclaimOutstandingOffer(rootTopic, offerId)\n )\n deleteRoomOfferBookkeeping(rootTopic)\n}\n\nconst pruneOutstandingOffers = (rootTopic: string): void => {\n const now = Date.now()\n\n entries(getRoomOutstandingOffers(rootTopic)).forEach(([offerId, offer]) => {\n if (now - offer.createdAt > offerRetentionMs) {\n reclaimOutstandingOffer(rootTopic, offerId)\n }\n })\n}\n\nconst ensureOutstandingOffers = async (\n rootTopic: string,\n getOffers: (n: number) => Promise<OfferRecord[]>\n): Promise<Record<string, OfferRecord & {createdAt: number}>> => {\n while (roomOfferGenerationPromises[rootTopic]) {\n await roomOfferGenerationPromises[rootTopic]\n }\n\n const nextPromise = (async () => {\n pruneOutstandingOffers(rootTopic)\n\n const outstandingOffers = getRoomOutstandingOffers(rootTopic)\n const outstandingCount = keys(outstandingOffers).length\n const missingOffers = Math.max(0, offerPoolSize - outstandingCount)\n\n if (missingOffers > 0) {\n ;(await getOffers(missingOffers)).forEach(peerAndOffer => {\n outstandingOffers[genId(hashLimit)] = {\n ...peerAndOffer,\n createdAt: Date.now()\n }\n })\n }\n })().finally(() => {\n if (roomOfferGenerationPromises[rootTopic] === nextPromise) {\n delete roomOfferGenerationPromises[rootTopic]\n }\n })\n\n roomOfferGenerationPromises[rootTopic] = nextPromise\n await nextPromise\n\n return getRoomOutstandingOffers(rootTopic)\n}\n\nconst joinRoomStrategy: JoinRoom<TorrentRoomConfig> = createStrategy({\n init: config =>\n getRelays(config, defaultRelayUrls, defaultRedundancy).map(rawUrl => {\n const client = relayManager.register(\n rawUrl,\n makeSocket(rawUrl, rawData => {\n const data = fromJson<TrackerMessage>(rawData)\n const errMsg = data['failure reason']\n const warnMsg = data['warning message']\n const {interval} = data\n const topic = data.info_hash\n ? infoHashToTopic[data.info_hash]\n : undefined\n\n if (errMsg) {\n warn(client.url, errMsg, true)\n return\n }\n\n if (warnMsg) {\n warn(client.url, warnMsg)\n }\n\n if (\n interval &&\n interval * 1000 >\n (trackerAnnounceMs[client.url] ?? defaultAnnounceMs) &&\n topic &&\n announceFns.forKey(rawUrl)[topic]\n ) {\n const nextInterval = Math.min(interval * 1000, maxAnnounceMs)\n const relayIntervals = announceIntervals.forKey(rawUrl)\n const relayFns = announceFns.forKey(rawUrl)\n\n if (relayIntervals[topic]) {\n clearInterval(relayIntervals[topic])\n }\n trackerAnnounceMs[client.url] = nextInterval\n const relayFn = relayFns[topic]\n\n if (relayFn) {\n relayIntervals[topic] = setInterval(() => {\n void relayFn()\n }, nextInterval)\n }\n }\n\n if ((data.offer || data.answer) && topic && data.offer_id) {\n if (data.peer_id === selfId) {\n return\n }\n\n const signalType = data.offer ? 'offer' : 'answer'\n const signalKey = `${topic}:${signalType}:${data.offer_id}:${data.peer_id ?? ''}`\n const nowMs = Date.now()\n const lastHandledMs = handledSignals[signalKey]\n\n if (\n typeof lastHandledMs === 'number' &&\n nowMs - lastHandledMs < signalDedupeWindowMs\n ) {\n return\n }\n\n handledSignals[signalKey] = nowMs\n\n entries(handledSignals).forEach(([key, handledAtMs]) => {\n if (nowMs - handledAtMs > signalDedupeWindowMs * 6) {\n delete handledSignals[key]\n }\n })\n\n msgHandlers.forKey(rawUrl)[topic]?.(data)\n }\n })\n )\n\n return client.ready\n }),\n\n subscribe: (client, rootTopic, _, onMessage, getOffers) => {\n const handlers = msgHandlers.forRelay(client)\n const relayFns = announceFns.forRelay(client)\n const relayIntervals = announceIntervals.forRelay(client)\n const activeTokens = subscriptionTokens.forRelay(client)\n const subscriptionToken = Symbol(rootTopic)\n\n activeTokens[rootTopic] = subscriptionToken\n roomSubscriberCounts[rootTopic] = (roomSubscriberCounts[rootTopic] ?? 0) + 1\n\n const topicHandler = (data: TrackerMessage): void => {\n if (data.offer && data.peer_id && data.offer_id) {\n void onMessage(\n rootTopic,\n {\n offer: data.offer.sdp,\n peerId: data.peer_id,\n hasOutgoingOffer:\n keys(getRoomOutstandingOffers(rootTopic)).length > 0\n },\n (_, signal) =>\n void send(client, rootTopic, {\n answer: {\n type: 'answer',\n sdp: fromJson<{answer: string}>(signal).answer\n },\n offer_id: data.offer_id,\n to_peer_id: data.peer_id\n })\n )\n } else if (data.answer && data.offer_id && data.peer_id) {\n const offer = claimOutstandingOffer(rootTopic, data.offer_id)\n\n if (offer) {\n void onMessage(\n rootTopic,\n {\n answer: data.answer.sdp,\n peerId: data.peer_id,\n peer: offer.peer\n },\n () => {}\n )\n }\n }\n }\n\n handlers[rootTopic] = topicHandler\n\n const announce = async (): Promise<void> => {\n if (activeTokens[rootTopic] !== subscriptionToken) {\n return\n }\n\n const outstandingOffers = await ensureOutstandingOffers(\n rootTopic,\n getOffers\n )\n const offers = entries(outstandingOffers).map(([id, {offer}]) => ({\n offer_id: id,\n offer: {type: 'offer', sdp: offer}\n }))\n\n void send(client, rootTopic, {\n numwant: offerPoolSize,\n offers\n })\n }\n\n trackerAnnounceMs[client.url] = defaultAnnounceMs\n relayFns[rootTopic] = announce\n relayIntervals[rootTopic] = setInterval(\n announce,\n trackerAnnounceMs[client.url]\n )\n void announce()\n\n return () => {\n roomSubscriberCounts[rootTopic] = Math.max(\n 0,\n (roomSubscriberCounts[rootTopic] ?? 1) - 1\n )\n\n if (!roomSubscriberCounts[rootTopic]) {\n delete roomSubscriberCounts[rootTopic]\n }\n\n if (activeTokens[rootTopic] !== subscriptionToken) {\n if (!roomSubscriberCounts[rootTopic]) {\n reclaimAllOutstandingOffers(rootTopic)\n }\n\n return\n }\n\n const interval = relayIntervals[rootTopic]\n if (interval) {\n clearInterval(interval)\n delete relayIntervals[rootTopic]\n }\n\n if (handlers[rootTopic] === topicHandler) {\n delete handlers[rootTopic]\n }\n\n if (relayFns[rootTopic] === announce) {\n delete relayFns[rootTopic]\n }\n\n delete activeTokens[rootTopic]\n\n if (!roomSubscriberCounts[rootTopic]) {\n reclaimAllOutstandingOffers(rootTopic)\n }\n }\n },\n\n announce: client => trackerAnnounceMs[client.url]\n})\n\nexport const joinRoom: JoinRoom<TorrentRoomConfig> = (\n config,\n roomId,\n callbacks\n) =>\n joinRoomStrategy(\n {\n ...config,\n trickleIce: config.trickleIce ?? false\n },\n roomId,\n callbacks\n )\n\nexport const getRelaySockets = relayManager.getSockets\n\nexport {pauseRelayReconnection, resumeRelayReconnection, selfId}\n\nexport const defaultRelayUrls = [\n 'tracker.webtorrent.dev',\n 'tracker.openwebtorrent.com',\n 'tracker.btorrent.xyz',\n 'tracker.files.fm:7073/announce'\n].map(url => 'wss://' + url)\n\nexport type * from '@trystero-p2p/core'\n"],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trystero-p2p/torrent",
3
- "version": "0.23.0",
3
+ "version": "0.24.0",
4
4
  "description": "Torrent tracker strategy for Trystero",
5
5
  "keywords": [
6
6
  "bittorrent",
@@ -34,6 +34,6 @@
34
34
  "./package.json": "./package.json"
35
35
  },
36
36
  "dependencies": {
37
- "@trystero-p2p/core": "0.23.0"
37
+ "@trystero-p2p/core": "0.24.0"
38
38
  }
39
39
  }