@trystero-p2p/torrent 0.26.0 → 0.27.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/dist/index.d.mts CHANGED
@@ -1,10 +1,7 @@
1
- import { JoinRoom, JoinRoomConfig, pauseRelayReconnection, resumeRelayReconnection, selfId } from "@trystero-p2p/core";
2
- export type * from "@trystero-p2p/core";
3
- //#region src/index.d.ts
1
+ import { JoinRoom, JoinRoomConfig, clearUserIdentity, createUserIdentity, exportUserIdentity, importUserIdentity, roles, scale, setRelayConnectivity } from "@trystero-p2p/core";
2
+ export type * from "@trystero-p2p/core/types";
4
3
  export type TorrentRoomConfig = JoinRoomConfig;
5
4
  export declare const joinRoom: JoinRoom<TorrentRoomConfig>;
6
- export declare const getRelaySockets: any;
5
+ export declare const getRelaySockets: () => Record<string, WebSocket>;
7
6
  export declare const defaultRelayUrls: string[];
8
- //#endregion
9
- export { pauseRelayReconnection, resumeRelayReconnection, selfId };
10
- //# sourceMappingURL=index.d.mts.map
7
+ export { clearUserIdentity, createUserIdentity, exportUserIdentity, importUserIdentity, roles, scale, setRelayConnectivity };
package/dist/index.mjs CHANGED
@@ -1,13 +1,14 @@
1
- import { createRelayManager, createStrategy, entries, fromJson, genId, getRelays, keys, libName, makeSocket, pauseRelayReconnection, resumeRelayReconnection, selfId, sha1, toJson } from "@trystero-p2p/core";
2
- //#region src/index.ts
3
- const relayManager = createRelayManager((client) => client.socket);
4
- const topicToInfoHash = {};
5
- const infoHashToTopic = {};
6
- const handledSignals = {};
7
- const topicStates = relayManager.scoped();
8
- const roomOutstandingOffers = {};
9
- const roomOfferGenerationPromises = {};
10
- const roomSubscriberCounts = {};
1
+ import { clearUserIdentity, createRelayManager, createRelayScopedStore, createStrategy, createUserIdentity, exportUserIdentity, fromJson, getRelays, hashTopic, importUserIdentity, libName, makeSocket, mkErr, roles, scale, setRelayConnectivity, toJson, unrefTimer } from "@trystero-p2p/core";
2
+
3
+ // src/index.ts
4
+ const unreachable = (x) => {
5
+ throw mkErr(`unreachable: ${String(x)}`);
6
+ };
7
+ const relayManager = /*@__PURE__*/ createRelayManager((client) => client.socket, (client) => client.close?.());
8
+ const topicSessions = /*@__PURE__*/ new Map();
9
+ const infoHashToTopic = /*@__PURE__*/ new Map();
10
+ const handledSignals = /*@__PURE__*/ new Map();
11
+ const topicStates = /*@__PURE__*/ createRelayScopedStore();
11
12
  const trackerAction = "announce";
12
13
  const hashLimit = 20;
13
14
  const offersPerAnnounce = 1;
@@ -17,108 +18,216 @@ const dormantAnnounceMs = 12e4;
17
18
  const offerRetentionMs = 12e4;
18
19
  const signalDedupeWindowMs = 4e3;
19
20
  const defaultRedundancy = 3;
20
- const getInfoHash = async (topic) => {
21
- if (topicToInfoHash[topic]) return topicToInfoHash[topic];
22
- const hash = (await sha1(topic)).slice(0, hashLimit);
23
- topicToInfoHash[topic] = hash;
24
- infoHashToTopic[hash] = topic;
25
- return hash;
21
+ const resolveSessionInfoHash = (topicSession) => {
22
+ if (topicSession.closed) return Promise.resolve(null);
23
+ if (topicSession.infoHash !== null) return Promise.resolve(topicSession.infoHash);
24
+ return topicSession.infoHashP ??= hashTopic(topicSession.rootTopic).then((fullHash) => {
25
+ if (topicSession.closed) return null;
26
+ const hash = fullHash.slice(0, hashLimit);
27
+ topicSession.infoHash = hash;
28
+ infoHashToTopic.set(hash, topicSession.rootTopic);
29
+ return hash;
30
+ }, (err) => {
31
+ topicSession.infoHashP = null;
32
+ throw err;
33
+ });
26
34
  };
27
- const send = async (client, topic, payload) => client.send(toJson({
28
- action: trackerAction,
29
- info_hash: await getInfoHash(topic),
30
- peer_id: selfId,
31
- ...payload
32
- }));
33
- const warn = (url, msg, didFail = false) => console.warn(`${libName}: torrent tracker ${didFail ? "failure" : "warning"} from ${url} - ${msg}`);
34
- const getRoomOutstandingOffers = (rootTopic) => roomOutstandingOffers[rootTopic] ??= {};
35
- const deleteRoomOfferBookkeeping = (rootTopic) => {
36
- delete roomOutstandingOffers[rootTopic];
37
- delete roomOfferGenerationPromises[rootTopic];
35
+ const acquirePeerSession = (rootTopic, peerId) => {
36
+ let topicSession = topicSessions.get(rootTopic);
37
+ if (!topicSession || topicSession.closed) {
38
+ topicSession = {
39
+ rootTopic,
40
+ closed: false,
41
+ infoHash: null,
42
+ infoHashP: null,
43
+ peers: /* @__PURE__ */ new Map()
44
+ };
45
+ topicSessions.set(rootTopic, topicSession);
46
+ resolveSessionInfoHash(topicSession).catch(() => {});
47
+ }
48
+ let peerSession = topicSession.peers.get(peerId);
49
+ if (!peerSession) {
50
+ peerSession = {
51
+ subscriberCount: 0,
52
+ nextGeneration: 0,
53
+ pool: {
54
+ phase: "idle",
55
+ offers: /* @__PURE__ */ new Map()
56
+ }
57
+ };
58
+ topicSession.peers.set(peerId, peerSession);
59
+ }
60
+ peerSession.subscriberCount += 1;
61
+ return {
62
+ topicSession,
63
+ peerSession
64
+ };
65
+ };
66
+ const disposeOfferPool = (peerSession) => {
67
+ switch (peerSession.pool.phase) {
68
+ case "idle":
69
+ case "generating": {
70
+ const existingOffers = [...peerSession.pool.offers.values()];
71
+ peerSession.pool.offers.clear();
72
+ peerSession.pool = { phase: "closed" };
73
+ existingOffers.forEach((offer) => {
74
+ offer.reclaim?.();
75
+ });
76
+ return;
77
+ }
78
+ case "closed": return;
79
+ default: unreachable(peerSession.pool);
80
+ }
38
81
  };
39
- const takeOutstandingOffer = (rootTopic, offerId, action) => {
40
- const outstandingOffers = roomOutstandingOffers[rootTopic];
41
- const offer = outstandingOffers?.[offerId];
82
+ const releasePeerSession = (topicSession, peerId, peerSession) => {
83
+ peerSession.subscriberCount = Math.max(0, peerSession.subscriberCount - 1);
84
+ if (peerSession.subscriberCount === 0) {
85
+ disposeOfferPool(peerSession);
86
+ if (topicSession.peers.get(peerId) === peerSession) topicSession.peers.delete(peerId);
87
+ }
88
+ if (topicSession.peers.size === 0 && !topicSession.closed) {
89
+ topicSession.closed = true;
90
+ if (topicSession.infoHash !== null) infoHashToTopic.delete(topicSession.infoHash);
91
+ if (topicSessions.get(topicSession.rootTopic) === topicSession) topicSessions.delete(topicSession.rootTopic);
92
+ }
93
+ };
94
+ const send = (client, topicSession, peerId, payload) => {
95
+ const transmit = (infoHash) => {
96
+ if (infoHash === null || client.isClosed) return;
97
+ client.send(toJson({
98
+ action: trackerAction,
99
+ info_hash: infoHash,
100
+ peer_id: peerId,
101
+ ...payload
102
+ }));
103
+ };
104
+ if (topicSession.infoHash !== null && !topicSession.closed) {
105
+ transmit(topicSession.infoHash);
106
+ return Promise.resolve();
107
+ }
108
+ return resolveSessionInfoHash(topicSession).then(transmit);
109
+ };
110
+ const warn = (url, msg, didFail = false) => console.warn(`${libName}: torrent tracker ${didFail ? "failure" : "warning"} from ${url} - ${msg}`);
111
+ const takeOutstandingOffer = (peerSession, offerId, action) => {
112
+ if (peerSession.pool.phase === "closed") return;
113
+ const offer = peerSession.pool.offers.get(offerId);
42
114
  if (!offer) return;
43
- delete outstandingOffers[offerId];
115
+ peerSession.pool.offers.delete(offerId);
44
116
  offer[action]?.();
45
- if (!keys(outstandingOffers).length && !roomSubscriberCounts[rootTopic]) deleteRoomOfferBookkeeping(rootTopic);
46
117
  return offer;
47
118
  };
48
- const reclaimAllOutstandingOffers = (rootTopic) => {
49
- keys(getRoomOutstandingOffers(rootTopic)).forEach((offerId) => takeOutstandingOffer(rootTopic, offerId, "reclaim"));
50
- deleteRoomOfferBookkeeping(rootTopic);
51
- };
52
- const pruneOutstandingOffers = (rootTopic) => {
119
+ const pruneOutstandingOffers = (peerSession) => {
120
+ if (peerSession.pool.phase === "closed") return;
53
121
  const now = Date.now();
54
- entries(getRoomOutstandingOffers(rootTopic)).forEach(([offerId, offer]) => {
55
- if (now - offer.createdAt > offerRetentionMs) takeOutstandingOffer(rootTopic, offerId, "reclaim");
56
- });
122
+ for (const [offerId, offer] of peerSession.pool.offers) if (now - offer.createdAt > offerRetentionMs) takeOutstandingOffer(peerSession, offerId, "reclaim");
57
123
  };
58
- const ensureOutstandingOffers = async (rootTopic, getOffers) => {
59
- while (roomOfferGenerationPromises[rootTopic]) await roomOfferGenerationPromises[rootTopic];
60
- const nextPromise = (async () => {
61
- pruneOutstandingOffers(rootTopic);
62
- const outstandingOffers = getRoomOutstandingOffers(rootTopic);
63
- const outstandingCount = keys(outstandingOffers).length;
64
- const missingOffers = Math.max(0, offersPerAnnounce - outstandingCount);
65
- if (missingOffers > 0) (await getOffers(missingOffers)).forEach((peerAndOffer) => {
66
- outstandingOffers[genId(hashLimit)] = {
124
+ const ensureOutstandingOffers = async (peerSession, getOffers) => {
125
+ while (peerSession.pool.phase === "generating") await peerSession.pool.promise;
126
+ if (peerSession.pool.phase === "closed") return null;
127
+ pruneOutstandingOffers(peerSession);
128
+ if (peerSession.pool.phase !== "idle") return null;
129
+ const { offers } = peerSession.pool;
130
+ const missingOffers = Math.max(0, offersPerAnnounce - offers.size);
131
+ if (missingOffers === 0) return offers;
132
+ const generation = ++peerSession.nextGeneration;
133
+ const { promise, resolve } = Promise.withResolvers();
134
+ peerSession.pool = {
135
+ phase: "generating",
136
+ offers,
137
+ promise,
138
+ generation
139
+ };
140
+ try {
141
+ const allocated = await getOffers(missingOffers);
142
+ if (peerSession.pool.phase !== "generating" || peerSession.pool.generation !== generation) {
143
+ allocated.forEach((offer) => {
144
+ offer.reclaim?.();
145
+ });
146
+ return null;
147
+ }
148
+ const createdAt = Date.now();
149
+ allocated.forEach((peerAndOffer) => {
150
+ offers.set(peerAndOffer.offerId, {
67
151
  ...peerAndOffer,
68
- createdAt: Date.now()
69
- };
152
+ createdAt
153
+ });
70
154
  });
71
- })().finally(() => {
72
- if (roomOfferGenerationPromises[rootTopic] === nextPromise) delete roomOfferGenerationPromises[rootTopic];
73
- });
74
- roomOfferGenerationPromises[rootTopic] = nextPromise;
75
- await nextPromise;
76
- return getRoomOutstandingOffers(rootTopic);
155
+ peerSession.pool = {
156
+ phase: "idle",
157
+ offers
158
+ };
159
+ return offers;
160
+ } catch (err) {
161
+ if (peerSession.pool.phase === "generating" && peerSession.pool.generation === generation) peerSession.pool = {
162
+ phase: "idle",
163
+ offers
164
+ };
165
+ throw err;
166
+ } finally {
167
+ resolve();
168
+ }
77
169
  };
78
- const joinRoomStrategy = createStrategy({
170
+ const joinRoom = /*@__PURE__*/ createStrategy({
171
+ defaultConfig: { trickleIce: false },
79
172
  init: (config) => getRelays(config, defaultRelayUrls, defaultRedundancy).map((rawUrl) => {
80
- const client = relayManager.register(rawUrl, () => makeSocket(rawUrl, (rawData) => {
81
- const data = fromJson(rawData);
82
- const errMsg = data["failure reason"];
83
- const warnMsg = data["warning message"];
84
- const { interval } = data;
85
- const topic = data.info_hash ? infoHashToTopic[data.info_hash] : void 0;
86
- if (errMsg) {
87
- if (config.relayConfig?.warnOnRelayFailure !== false) warn(client.url, errMsg, true);
88
- return;
89
- }
90
- if (warnMsg && config.relayConfig?.warnOnRelayFailure !== false) warn(client.url, warnMsg);
91
- const state = topic ? topicStates.forKey(rawUrl)[topic] : void 0;
92
- if (interval && state) state.announceMs = Math.min(Math.max(interval * 1e3, defaultAnnounceMs), offerRetentionMs);
93
- if ((data.offer || data.answer) && topic && data.offer_id) {
94
- if (data.peer_id === selfId) return;
95
- const signalKey = `${topic}:${data.offer ? "offer" : "answer"}:${data.offer_id}:${data.peer_id ?? ""}`;
96
- const nowMs = Date.now();
97
- const lastHandledMs = handledSignals[signalKey];
98
- if (typeof lastHandledMs === "number" && nowMs - lastHandledMs < signalDedupeWindowMs) return;
99
- handledSignals[signalKey] = nowMs;
100
- entries(handledSignals).forEach(([key, handledAtMs]) => {
101
- if (nowMs - handledAtMs > signalDedupeWindowMs * 6) delete handledSignals[key];
102
- });
103
- state?.handler(data);
104
- }
105
- }, () => Object.values(topicStates.forKey(rawUrl)).forEach((state) => void state.announce())));
106
- return client.ready;
173
+ const client = relayManager.register(rawUrl, () => {
174
+ const created = makeSocket(rawUrl, (rawData) => {
175
+ const parsed = fromJson(rawData);
176
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return;
177
+ const data = parsed;
178
+ const errMsg = data["failure reason"];
179
+ const warnMsg = data["warning message"];
180
+ const { interval } = data;
181
+ const topic = typeof data.info_hash === "string" ? infoHashToTopic.get(data.info_hash) : void 0;
182
+ if (errMsg) {
183
+ if (config.relayConfig?.warnOnRelayFailure !== false) warn(created.url, errMsg, true);
184
+ return;
185
+ }
186
+ if (warnMsg && config.relayConfig?.warnOnRelayFailure !== false) warn(created.url, warnMsg);
187
+ const peerMap = topic ? topicStates.forRelay(created)[topic] : void 0;
188
+ if (!peerMap || !topic) return;
189
+ if (interval) {
190
+ const clampedMs = Math.min(Math.max(interval * 1e3, defaultAnnounceMs), offerRetentionMs);
191
+ peerMap.forEach((state) => {
192
+ state.announceMs = clampedMs;
193
+ });
194
+ }
195
+ if ((data.offer || data.answer) && data.offer_id) {
196
+ const signalType = data.offer ? "offer" : "answer";
197
+ const nowMs = Date.now();
198
+ for (const [key, handledAtMs] of handledSignals) if (nowMs - handledAtMs > signalDedupeWindowMs * 6) handledSignals.delete(key);
199
+ for (const state of peerMap.values()) {
200
+ if (state.peerId && data.peer_id === state.peerId) continue;
201
+ if (data.to_peer_id && state.peerId && data.to_peer_id !== state.peerId) continue;
202
+ const signalKey = `${topic}:${state.peerId}:${signalType}:${data.offer_id}:${data.peer_id ?? ""}`;
203
+ const lastHandledMs = handledSignals.get(signalKey);
204
+ if (typeof lastHandledMs === "number" && nowMs - lastHandledMs < signalDedupeWindowMs) continue;
205
+ handledSignals.set(signalKey, nowMs);
206
+ state.handler(data);
207
+ }
208
+ }
209
+ }, () => Object.values(topicStates.forRelay(created)).forEach((peerMap) => peerMap.forEach((state) => void state.announce())));
210
+ return created;
211
+ });
212
+ return relayManager.lease(client, client.ready);
107
213
  }),
108
214
  subscribe: (client, rootTopic, _, onMessage, getOffers, context) => {
109
- const states = topicStates.forRelay(client);
215
+ const relayTopics = topicStates.forRelay(client);
110
216
  const subscriptionToken = Symbol(rootTopic);
111
- roomSubscriberCounts[rootTopic] = (roomSubscriberCounts[rootTopic] ?? 0) + 1;
112
- let passiveAnnounceTimeout;
113
- const stopPassiveAnnouncements = () => {
114
- clearTimeout(passiveAnnounceTimeout);
115
- passiveAnnounceTimeout = void 0;
116
- };
217
+ const isPassive = Boolean(context?.config.passive);
218
+ const peerId = context?.peerId ?? "";
219
+ const { topicSession, peerSession } = acquirePeerSession(rootTopic, peerId);
220
+ const peerMap = relayTopics[rootTopic] ??= /* @__PURE__ */ new Map();
221
+ const prevState = peerMap.get(peerId);
222
+ if (prevState && prevState.passiveTimer !== null) {
223
+ clearTimeout(prevState.passiveTimer);
224
+ prevState.passiveTimer = null;
225
+ }
117
226
  const topicState = {
118
227
  announce: async () => {
119
- if (states[rootTopic]?.token !== subscriptionToken) return;
120
- if (!topicState.isActive) {
121
- send(client, rootTopic, {
228
+ if (peerMap.get(peerId)?.token !== subscriptionToken) return;
229
+ if (isPassive) {
230
+ await send(client, topicSession, peerId, {
122
231
  left: 0,
123
232
  numwant: requestedPeers,
124
233
  offers: []
@@ -127,20 +236,20 @@ const joinRoomStrategy = createStrategy({
127
236
  }
128
237
  let outstandingOffers;
129
238
  try {
130
- outstandingOffers = await ensureOutstandingOffers(rootTopic, getOffers);
239
+ outstandingOffers = await ensureOutstandingOffers(peerSession, getOffers);
131
240
  } catch (error) {
132
- if (states[rootTopic]?.token !== subscriptionToken) return;
241
+ if (peerMap.get(peerId)?.token !== subscriptionToken) return;
133
242
  throw error;
134
243
  }
135
- if (states[rootTopic]?.token !== subscriptionToken || !topicState.isActive) return;
136
- const offers = entries(outstandingOffers).map(([id, { offer }]) => ({
244
+ if (!outstandingOffers || peerMap.get(peerId)?.token !== subscriptionToken) return;
245
+ const offers = [...outstandingOffers.entries()].map(([id, { offer }]) => ({
137
246
  offer_id: id,
138
247
  offer: {
139
248
  type: "offer",
140
249
  sdp: offer
141
250
  }
142
251
  }));
143
- send(client, rootTopic, {
252
+ await send(client, topicSession, peerId, {
144
253
  numwant: requestedPeers,
145
254
  offers
146
255
  });
@@ -151,7 +260,7 @@ const joinRoomStrategy = createStrategy({
151
260
  offer: data.offer.sdp,
152
261
  offerId: data.offer_id,
153
262
  peerId: data.peer_id
154
- }, (_, signal) => void send(client, rootTopic, {
263
+ }, (_, signal) => void send(client, topicSession, peerId, {
155
264
  answer: {
156
265
  type: "answer",
157
266
  sdp: fromJson(signal).answer
@@ -160,7 +269,7 @@ const joinRoomStrategy = createStrategy({
160
269
  to_peer_id: data.peer_id
161
270
  }));
162
271
  else if (data.answer && data.offer_id && data.peer_id) {
163
- const offer = takeOutstandingOffer(rootTopic, data.offer_id, "claim");
272
+ const offer = takeOutstandingOffer(peerSession, data.offer_id, "claim");
164
273
  if (offer) {
165
274
  onMessage(rootTopic, {
166
275
  answer: data.answer.sdp,
@@ -172,57 +281,50 @@ const joinRoomStrategy = createStrategy({
172
281
  }
173
282
  }
174
283
  },
175
- isActive: !context?.isPassive,
176
- startPassiveAnnouncements: () => {
177
- stopPassiveAnnouncements();
178
- if (states[rootTopic]?.token !== subscriptionToken || topicState.isActive) return;
179
- topicState.announce();
180
- passiveAnnounceTimeout = setTimeout(topicState.startPassiveAnnouncements, Math.max(topicState.announceMs, dormantAnnounceMs));
181
- },
182
- stopPassiveAnnouncements,
284
+ peerId,
285
+ passiveTimer: null,
183
286
  token: subscriptionToken
184
287
  };
185
- states[rootTopic] = topicState;
186
- if (!topicState.isActive) topicState.startPassiveAnnouncements();
288
+ peerMap.set(peerId, topicState);
289
+ if (isPassive) {
290
+ const schedulePassiveAnnounce = () => {
291
+ if (peerMap.get(peerId)?.token !== subscriptionToken) return;
292
+ topicState.announce();
293
+ if (peerMap.get(peerId)?.token !== subscriptionToken) return;
294
+ if (topicState.passiveTimer !== null) clearTimeout(topicState.passiveTimer);
295
+ topicState.passiveTimer = setTimeout(schedulePassiveAnnounce, Math.max(topicState.announceMs, dormantAnnounceMs));
296
+ unrefTimer(topicState.passiveTimer);
297
+ };
298
+ schedulePassiveAnnounce();
299
+ }
300
+ let closed = false;
187
301
  return () => {
188
- topicState.stopPassiveAnnouncements();
189
- roomSubscriberCounts[rootTopic] = Math.max(0, (roomSubscriberCounts[rootTopic] ?? 1) - 1);
190
- if (!roomSubscriberCounts[rootTopic]) {
191
- delete roomSubscriberCounts[rootTopic];
192
- reclaimAllOutstandingOffers(rootTopic);
302
+ if (closed) return;
303
+ closed = true;
304
+ if (topicState.passiveTimer !== null) {
305
+ clearTimeout(topicState.passiveTimer);
306
+ topicState.passiveTimer = null;
307
+ }
308
+ if (peerMap.get(peerId)?.token === subscriptionToken) {
309
+ peerMap.delete(peerId);
310
+ if (peerMap.size === 0 && relayTopics[rootTopic] === peerMap) delete relayTopics[rootTopic];
193
311
  }
194
- if (states[rootTopic]?.token === subscriptionToken) delete states[rootTopic];
312
+ releasePeerSession(topicSession, peerId, peerSession);
195
313
  };
196
314
  },
197
- announce: async (client, rootTopic) => {
198
- const state = topicStates.forRelay(client)[rootTopic];
199
- if (state) {
200
- state.stopPassiveAnnouncements();
201
- state.isActive = true;
202
- await state.announce();
203
- }
315
+ announce: async (client, rootTopic, _selfTopic, context) => {
316
+ const state = topicStates.forRelay(client)[rootTopic]?.get(context?.peerId ?? "");
317
+ await state?.announce();
204
318
  return state?.announceMs ?? defaultAnnounceMs;
205
- },
206
- deactivate: (client, rootTopic) => {
207
- const state = topicStates.forRelay(client)[rootTopic];
208
- if (state) state.isActive = false;
209
- reclaimAllOutstandingOffers(rootTopic);
210
- state?.startPassiveAnnouncements();
211
319
  }
212
320
  });
213
- const joinRoom = (config, roomId, callbacks) => joinRoomStrategy({
214
- ...config,
215
- trickleIce: config.trickleIce ?? false
216
- }, roomId, callbacks);
217
- const getRelaySockets = relayManager.getSockets;
321
+ const getRelaySockets = () => relayManager.getSockets();
218
322
  const defaultRelayUrls = [
219
- "open.ftorrent.com",
220
- "tracker.webtorrent.dev",
221
- "tracker.openwebtorrent.com",
222
- "tracker.btorrent.xyz",
223
- "tracker.files.fm:7073/announce"
224
- ].map((url) => "wss://" + url);
225
- //#endregion
226
- export { defaultRelayUrls, getRelaySockets, joinRoom, pauseRelayReconnection, resumeRelayReconnection, selfId };
323
+ "wss://open.ftorrent.com",
324
+ "wss://tracker.webtorrent.dev",
325
+ "wss://tracker.openwebtorrent.com",
326
+ "wss://tracker.btorrent.xyz",
327
+ "wss://tracker.files.fm:7073/announce"
328
+ ];
227
329
 
228
- //# sourceMappingURL=index.mjs.map
330
+ export { clearUserIdentity, createUserIdentity, defaultRelayUrls, exportUserIdentity, getRelaySockets, importUserIdentity, joinRoom, roles, scale, setRelayConnectivity };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trystero-p2p/torrent",
3
- "version": "0.26.0",
3
+ "version": "0.27.0",
4
4
  "description": "Simple, serverless WebRTC (via Torrent)",
5
5
  "keywords": [
6
6
  "bittorrent",
@@ -25,16 +25,25 @@
25
25
  "email": "dan@oxism.com",
26
26
  "url": "https://oxism.com"
27
27
  },
28
+ "repository": {
29
+ "type": "git",
30
+ "url": "git+https://github.com/dmotz/trystero.git",
31
+ "directory": "packages/torrent"
32
+ },
28
33
  "files": [
29
34
  "dist"
30
35
  ],
31
36
  "type": "module",
37
+ "sideEffects": false,
32
38
  "types": "./dist/index.d.mts",
33
39
  "exports": {
34
40
  ".": "./dist/index.mjs",
35
41
  "./package.json": "./package.json"
36
42
  },
37
43
  "dependencies": {
38
- "@trystero-p2p/core": "0.26.0"
44
+ "@trystero-p2p/core": "0.27.0"
45
+ },
46
+ "engines": {
47
+ "node": ">=22"
39
48
  }
40
49
  }
@@ -1 +0,0 @@
1
- {"version":3,"file":"index.d.mts","names":[],"sources":["../src/index.ts"],"mappings":";;;YAqDY,oBAAoB;qBAsXnB,UAAU,SAAS;qBAcnB;qBAIA"}
@@ -1 +0,0 @@
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> = {}\ntype TopicState = {\n announce: () => void | Promise<void>\n announceMs: number\n handler: (data: TrackerMessage) => void\n isActive: boolean\n startPassiveAnnouncements: () => void\n stopPassiveAnnouncements: () => void\n token: symbol\n}\n\nconst handledSignals: Record<string, number> = {}\nconst topicStates = relayManager.scoped<TopicState>()\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 offersPerAnnounce = 1\nconst requestedPeers = 3\nconst defaultAnnounceMs = 10_000\nconst dormantAnnounceMs = 120_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 takeOutstandingOffer = (\n rootTopic: string,\n offerId: string,\n action: 'claim' | 'reclaim'\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[action]?.()\n\n if (!keys(outstandingOffers).length && !roomSubscriberCounts[rootTopic]) {\n deleteRoomOfferBookkeeping(rootTopic)\n }\n\n return offer\n}\n\nconst reclaimAllOutstandingOffers = (rootTopic: string): void => {\n keys(getRoomOutstandingOffers(rootTopic)).forEach(offerId =>\n takeOutstandingOffer(rootTopic, offerId, 'reclaim')\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 takeOutstandingOffer(rootTopic, offerId, 'reclaim')\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, offersPerAnnounce - 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(rawUrl, () =>\n makeSocket(\n rawUrl,\n 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 if (config.relayConfig?.warnOnRelayFailure !== false) {\n warn(client.url, errMsg, true)\n }\n\n return\n }\n\n if (warnMsg && config.relayConfig?.warnOnRelayFailure !== false) {\n warn(client.url, warnMsg)\n }\n\n const state = topic ? topicStates.forKey(rawUrl)[topic] : undefined\n\n if (interval && state) {\n state.announceMs = Math.min(\n Math.max(interval * 1000, defaultAnnounceMs),\n offerRetentionMs\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 state?.handler(data)\n }\n },\n () =>\n Object.values(topicStates.forKey(rawUrl)).forEach(\n state => void state.announce()\n )\n )\n )\n\n return client.ready\n }),\n\n subscribe: (client, rootTopic, _, onMessage, getOffers, context) => {\n const states = topicStates.forRelay(client)\n const subscriptionToken = Symbol(rootTopic)\n\n roomSubscriberCounts[rootTopic] = (roomSubscriberCounts[rootTopic] ?? 0) + 1\n\n let passiveAnnounceTimeout: ReturnType<typeof setTimeout> | undefined\n const stopPassiveAnnouncements = (): void => {\n clearTimeout(passiveAnnounceTimeout)\n passiveAnnounceTimeout = undefined\n }\n const topicState: TopicState = {\n announce: async () => {\n if (states[rootTopic]?.token !== subscriptionToken) {\n return\n }\n\n if (!topicState.isActive) {\n void send(client, rootTopic, {\n left: 0,\n numwant: requestedPeers,\n offers: []\n })\n return\n }\n\n let outstandingOffers: Record<string, OfferRecord & {createdAt: number}>\n\n try {\n outstandingOffers = await ensureOutstandingOffers(\n rootTopic,\n getOffers\n )\n } catch (error) {\n if (states[rootTopic]?.token !== subscriptionToken) {\n return\n }\n\n throw error\n }\n\n if (\n states[rootTopic]?.token !== subscriptionToken ||\n !topicState.isActive\n ) {\n return\n }\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: requestedPeers,\n offers\n })\n },\n announceMs: defaultAnnounceMs,\n handler: (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 offerId: data.offer_id,\n peerId: data.peer_id\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 = takeOutstandingOffer(rootTopic, data.offer_id, 'claim')\n\n if (offer) {\n void onMessage(\n rootTopic,\n {\n answer: data.answer.sdp,\n offerId: data.offer_id,\n peerId: data.peer_id,\n peer: offer.peer\n },\n () => {}\n )\n void topicState.announce()\n }\n }\n },\n isActive: !context?.isPassive,\n startPassiveAnnouncements: (): void => {\n stopPassiveAnnouncements()\n\n if (\n states[rootTopic]?.token !== subscriptionToken ||\n topicState.isActive\n ) {\n return\n }\n\n void topicState.announce()\n passiveAnnounceTimeout = setTimeout(\n topicState.startPassiveAnnouncements,\n Math.max(topicState.announceMs, dormantAnnounceMs)\n )\n },\n stopPassiveAnnouncements,\n token: subscriptionToken\n }\n\n states[rootTopic] = topicState\n\n if (!topicState.isActive) {\n topicState.startPassiveAnnouncements()\n }\n\n return () => {\n topicState.stopPassiveAnnouncements()\n roomSubscriberCounts[rootTopic] = Math.max(\n 0,\n (roomSubscriberCounts[rootTopic] ?? 1) - 1\n )\n\n if (!roomSubscriberCounts[rootTopic]) {\n delete roomSubscriberCounts[rootTopic]\n reclaimAllOutstandingOffers(rootTopic)\n }\n\n if (states[rootTopic]?.token === subscriptionToken) {\n delete states[rootTopic]\n }\n }\n },\n\n announce: async (client, rootTopic) => {\n const state = topicStates.forRelay(client)[rootTopic]\n\n if (state) {\n state.stopPassiveAnnouncements()\n state.isActive = true\n await state.announce()\n }\n\n return state?.announceMs ?? defaultAnnounceMs\n },\n\n deactivate: (client, rootTopic) => {\n const state = topicStates.forRelay(client)[rootTopic]\n\n if (state) {\n state.isActive = false\n }\n\n reclaimAllOutstandingOffers(rootTopic)\n\n state?.startPassiveAnnouncements()\n }\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 'open.ftorrent.com',\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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"}