@torrent-tv/proxy 2.9.73 → 2.9.75
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/CHANGELOG.md +374 -362
- package/bin/cli.js +8 -0
- package/package.json +5 -2
- package/server.js +229 -228
- package/services/piece-store/disk-tier.js +151 -0
- package/services/piece-store/piece-lru.js +150 -0
- package/services/piece-store/shared-piece-store.js +416 -0
- package/services/torrent-pool.js +18 -2
- package/services/torrent-worker/channel.js +17 -13
- package/services/torrent-worker/client.js +264 -264
- package/services/torrent-worker/install-webrtc-shim.js +32 -0
- package/services/torrent-worker/pool-adapter.js +179 -179
- package/services/torrent-worker/webrtc-shim.js +219 -0
- package/services/torrent-worker/worker.js +49 -2
- package/test/piece-lru.test.js +109 -0
- package/test/shared-piece-store.test.js +302 -0
- package/test/webrtc-shim.test.js +150 -0
- package/test/worker-channel.test.js +120 -0
|
@@ -1,179 +1,179 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file `TorrentPool`'s interface, served from the worker thread.
|
|
3
|
-
*
|
|
4
|
-
* The routes, the planner, the health report and the session manager all reach
|
|
5
|
-
* for a torrent pool and use it the same handful of ways. Rather than rewrite
|
|
6
|
-
* every one of them to thread a `sourceKey` through and await what used to be
|
|
7
|
-
* immediate, this presents the shape they already expect and does the thread
|
|
8
|
-
* hop behind it. Swapping the implementation is then a one-line change at
|
|
9
|
-
* construction, and the call sites are untouched — which is what keeps a change
|
|
10
|
-
* of this size reviewable.
|
|
11
|
-
*
|
|
12
|
-
* Two accommodations are needed, and both are deliberate:
|
|
13
|
-
*
|
|
14
|
-
* - **`acquireFile` and `prioritizeByteRange` stay synchronous.** They return
|
|
15
|
-
* nothing the caller inspects, so the command is dispatched and not awaited.
|
|
16
|
-
* `acquireFile` hands back a release function exactly as before, which sends
|
|
17
|
-
* its own command when called. Awaiting them would mean touching every call
|
|
18
|
-
* site for no observable gain.
|
|
19
|
-
* - **`getTorrent` needs a `sourceKey`.** Torrent objects cannot cross a
|
|
20
|
-
* thread, so the worker keys them. Callers that have one pass it; the rest
|
|
21
|
-
* get one derived from the source itself, so the identity stays stable
|
|
22
|
-
* across calls for the same torrent.
|
|
23
|
-
*/
|
|
24
|
-
|
|
25
|
-
import crypto from "node:crypto";
|
|
26
|
-
import { TorrentWorkerClient } from "./client.js";
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Stable key for a source, matching how the worker keys its torrents.
|
|
30
|
-
*
|
|
31
|
-
* Derived from the source itself rather than handed out per request, so two
|
|
32
|
-
* routes asking for the same torrent name the same thing on the worker side.
|
|
33
|
-
*
|
|
34
|
-
* @param {"magnet" | "torrent"} sourceType
|
|
35
|
-
* @param {string} source
|
|
36
|
-
* @returns {string}
|
|
37
|
-
*/
|
|
38
|
-
function deriveSourceKey(sourceType, source) {
|
|
39
|
-
return `${sourceType}:${crypto.createHash("sha1").update(source).digest("hex")}`;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* A torrent pool whose work happens on another thread.
|
|
44
|
-
*
|
|
45
|
-
* See `protocol.js` for why: the torrent was taking ~85% of the main thread and
|
|
46
|
-
* everything owed to a viewer queued behind it.
|
|
47
|
-
*/
|
|
48
|
-
export class WorkerTorrentPool {
|
|
49
|
-
#client;
|
|
50
|
-
/** Stand-ins by source key, so repeat calls return the same object. */
|
|
51
|
-
#torrents = new Map();
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* @param {{ maxDiskBytes?: number }} [options]
|
|
55
|
-
*/
|
|
56
|
-
constructor(options = {}) {
|
|
57
|
-
this.#client = new TorrentWorkerClient(options);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Load (or join) a torrent and return a stand-in for it.
|
|
62
|
-
*
|
|
63
|
-
* @param {"magnet" | "torrent"} sourceType
|
|
64
|
-
* @param {string} source
|
|
65
|
-
* @returns {Promise<object>}
|
|
66
|
-
*/
|
|
67
|
-
async getTorrent(sourceType, source) {
|
|
68
|
-
const sourceKey = deriveSourceKey(sourceType, source);
|
|
69
|
-
const existing = this.#torrents.get(sourceKey);
|
|
70
|
-
if (existing) {
|
|
71
|
-
return existing;
|
|
72
|
-
}
|
|
73
|
-
const torrent = await this.#client.getTorrent({ sourceKey, sourceType, source });
|
|
74
|
-
this.#torrents.set(sourceKey, torrent);
|
|
75
|
-
return torrent;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
/**
|
|
79
|
-
* Claim a file for reading; the returned function releases it.
|
|
80
|
-
*
|
|
81
|
-
* Synchronous by design — see the file header.
|
|
82
|
-
*
|
|
83
|
-
* @param {object} torrent - A stand-in from {@link getTorrent}.
|
|
84
|
-
* @param {number} fileIndex
|
|
85
|
-
* @returns {() => void}
|
|
86
|
-
*/
|
|
87
|
-
acquireFile(torrent, fileIndex) {
|
|
88
|
-
const sourceKey = torrent?.sourceKey;
|
|
89
|
-
if (!sourceKey) {
|
|
90
|
-
return () => undefined;
|
|
91
|
-
}
|
|
92
|
-
// Dispatched, not awaited — callers use the result immediately and inspect
|
|
93
|
-
// nothing. But the release MUST NOT overtake it: both are ordinary messages
|
|
94
|
-
// to the worker, and if release arrives first the reader count drops to zero
|
|
95
|
-
// while a read is still running. The idle sweep then removes the torrent AND
|
|
96
|
-
// its downloaded data out from under the encoder — field 2026-08-02:
|
|
97
|
-
// "removed idle torrent ... and its store" mid-playback, after which every
|
|
98
|
-
// read hung and ffmpeg saw an empty input ("Stream ends prematurely at 0").
|
|
99
|
-
// Chaining the release onto the acquire keeps them in order.
|
|
100
|
-
const acquired = this.#client.acquireFile(sourceKey, fileIndex).catch(() => undefined);
|
|
101
|
-
let released = false;
|
|
102
|
-
return () => {
|
|
103
|
-
if (released) {
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
released = true;
|
|
107
|
-
void acquired.then(() => this.#client.releaseFile(sourceKey, fileIndex)).catch(() => undefined);
|
|
108
|
-
};
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* Live download figures for the progress display.
|
|
113
|
-
*
|
|
114
|
-
* @param {object} torrent
|
|
115
|
-
* @param {number | null} [fileIndex]
|
|
116
|
-
* @param {{ resumeAnchorByteStart?: number | null }} [options]
|
|
117
|
-
* @returns {Promise<object | null>}
|
|
118
|
-
*/
|
|
119
|
-
async getFileStats(torrent, fileIndex = null, options = {}) {
|
|
120
|
-
const sourceKey = torrent?.sourceKey;
|
|
121
|
-
if (!sourceKey) {
|
|
122
|
-
return null;
|
|
123
|
-
}
|
|
124
|
-
return this.#client.getFileStats({
|
|
125
|
-
sourceKey,
|
|
126
|
-
fileIndex,
|
|
127
|
-
resumeAnchorByteStart: options?.resumeAnchorByteStart ?? null
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
/**
|
|
132
|
-
* Reorder piece selection around a read position.
|
|
133
|
-
*
|
|
134
|
-
* Synchronous by design — see the file header.
|
|
135
|
-
*
|
|
136
|
-
* @param {object} torrent
|
|
137
|
-
* @param {number} fileIndex
|
|
138
|
-
* @param {number} byteStart
|
|
139
|
-
* @param {number} [windowBytes]
|
|
140
|
-
* @returns {void}
|
|
141
|
-
*/
|
|
142
|
-
prioritizeByteRange(torrent, fileIndex, byteStart, windowBytes) {
|
|
143
|
-
const sourceKey = torrent?.sourceKey;
|
|
144
|
-
if (!sourceKey) {
|
|
145
|
-
return;
|
|
146
|
-
}
|
|
147
|
-
void this.#client
|
|
148
|
-
.prioritizeByteRange({ sourceKey, fileIndex, byteStart, windowBytes })
|
|
149
|
-
.catch(() => undefined);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/**
|
|
153
|
-
* Pre-fetch the head and tail the codec probe needs.
|
|
154
|
-
*
|
|
155
|
-
* @param {object} torrent
|
|
156
|
-
* @param {number} fileIndex
|
|
157
|
-
* @param {number} [headBytes]
|
|
158
|
-
* @param {number} [tailBytes]
|
|
159
|
-
* @param {number} [timeoutMs]
|
|
160
|
-
* @returns {Promise<unknown>}
|
|
161
|
-
*/
|
|
162
|
-
async prefetchFileEdges(torrent, fileIndex, headBytes, tailBytes, timeoutMs) {
|
|
163
|
-
const sourceKey = torrent?.sourceKey;
|
|
164
|
-
if (!sourceKey) {
|
|
165
|
-
return null;
|
|
166
|
-
}
|
|
167
|
-
return this.#client.prefetchFileEdges({ sourceKey, fileIndex, headBytes, tailBytes, timeoutMs });
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Shut the torrent client down and stop the thread.
|
|
172
|
-
*
|
|
173
|
-
* @returns {Promise<void>}
|
|
174
|
-
*/
|
|
175
|
-
async destroyAll() {
|
|
176
|
-
this.#torrents.clear();
|
|
177
|
-
await this.#client.destroyAll();
|
|
178
|
-
}
|
|
179
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @file `TorrentPool`'s interface, served from the worker thread.
|
|
3
|
+
*
|
|
4
|
+
* The routes, the planner, the health report and the session manager all reach
|
|
5
|
+
* for a torrent pool and use it the same handful of ways. Rather than rewrite
|
|
6
|
+
* every one of them to thread a `sourceKey` through and await what used to be
|
|
7
|
+
* immediate, this presents the shape they already expect and does the thread
|
|
8
|
+
* hop behind it. Swapping the implementation is then a one-line change at
|
|
9
|
+
* construction, and the call sites are untouched — which is what keeps a change
|
|
10
|
+
* of this size reviewable.
|
|
11
|
+
*
|
|
12
|
+
* Two accommodations are needed, and both are deliberate:
|
|
13
|
+
*
|
|
14
|
+
* - **`acquireFile` and `prioritizeByteRange` stay synchronous.** They return
|
|
15
|
+
* nothing the caller inspects, so the command is dispatched and not awaited.
|
|
16
|
+
* `acquireFile` hands back a release function exactly as before, which sends
|
|
17
|
+
* its own command when called. Awaiting them would mean touching every call
|
|
18
|
+
* site for no observable gain.
|
|
19
|
+
* - **`getTorrent` needs a `sourceKey`.** Torrent objects cannot cross a
|
|
20
|
+
* thread, so the worker keys them. Callers that have one pass it; the rest
|
|
21
|
+
* get one derived from the source itself, so the identity stays stable
|
|
22
|
+
* across calls for the same torrent.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import crypto from "node:crypto";
|
|
26
|
+
import { TorrentWorkerClient } from "./client.js";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Stable key for a source, matching how the worker keys its torrents.
|
|
30
|
+
*
|
|
31
|
+
* Derived from the source itself rather than handed out per request, so two
|
|
32
|
+
* routes asking for the same torrent name the same thing on the worker side.
|
|
33
|
+
*
|
|
34
|
+
* @param {"magnet" | "torrent"} sourceType
|
|
35
|
+
* @param {string} source
|
|
36
|
+
* @returns {string}
|
|
37
|
+
*/
|
|
38
|
+
function deriveSourceKey(sourceType, source) {
|
|
39
|
+
return `${sourceType}:${crypto.createHash("sha1").update(source).digest("hex")}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A torrent pool whose work happens on another thread.
|
|
44
|
+
*
|
|
45
|
+
* See `protocol.js` for why: the torrent was taking ~85% of the main thread and
|
|
46
|
+
* everything owed to a viewer queued behind it.
|
|
47
|
+
*/
|
|
48
|
+
export class WorkerTorrentPool {
|
|
49
|
+
#client;
|
|
50
|
+
/** Stand-ins by source key, so repeat calls return the same object. */
|
|
51
|
+
#torrents = new Map();
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* @param {{ maxDiskBytes?: number, memoryBytes?: number }} [options]
|
|
55
|
+
*/
|
|
56
|
+
constructor(options = {}) {
|
|
57
|
+
this.#client = new TorrentWorkerClient(options);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Load (or join) a torrent and return a stand-in for it.
|
|
62
|
+
*
|
|
63
|
+
* @param {"magnet" | "torrent"} sourceType
|
|
64
|
+
* @param {string} source
|
|
65
|
+
* @returns {Promise<object>}
|
|
66
|
+
*/
|
|
67
|
+
async getTorrent(sourceType, source) {
|
|
68
|
+
const sourceKey = deriveSourceKey(sourceType, source);
|
|
69
|
+
const existing = this.#torrents.get(sourceKey);
|
|
70
|
+
if (existing) {
|
|
71
|
+
return existing;
|
|
72
|
+
}
|
|
73
|
+
const torrent = await this.#client.getTorrent({ sourceKey, sourceType, source });
|
|
74
|
+
this.#torrents.set(sourceKey, torrent);
|
|
75
|
+
return torrent;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Claim a file for reading; the returned function releases it.
|
|
80
|
+
*
|
|
81
|
+
* Synchronous by design — see the file header.
|
|
82
|
+
*
|
|
83
|
+
* @param {object} torrent - A stand-in from {@link getTorrent}.
|
|
84
|
+
* @param {number} fileIndex
|
|
85
|
+
* @returns {() => void}
|
|
86
|
+
*/
|
|
87
|
+
acquireFile(torrent, fileIndex) {
|
|
88
|
+
const sourceKey = torrent?.sourceKey;
|
|
89
|
+
if (!sourceKey) {
|
|
90
|
+
return () => undefined;
|
|
91
|
+
}
|
|
92
|
+
// Dispatched, not awaited — callers use the result immediately and inspect
|
|
93
|
+
// nothing. But the release MUST NOT overtake it: both are ordinary messages
|
|
94
|
+
// to the worker, and if release arrives first the reader count drops to zero
|
|
95
|
+
// while a read is still running. The idle sweep then removes the torrent AND
|
|
96
|
+
// its downloaded data out from under the encoder — field 2026-08-02:
|
|
97
|
+
// "removed idle torrent ... and its store" mid-playback, after which every
|
|
98
|
+
// read hung and ffmpeg saw an empty input ("Stream ends prematurely at 0").
|
|
99
|
+
// Chaining the release onto the acquire keeps them in order.
|
|
100
|
+
const acquired = this.#client.acquireFile(sourceKey, fileIndex).catch(() => undefined);
|
|
101
|
+
let released = false;
|
|
102
|
+
return () => {
|
|
103
|
+
if (released) {
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
released = true;
|
|
107
|
+
void acquired.then(() => this.#client.releaseFile(sourceKey, fileIndex)).catch(() => undefined);
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Live download figures for the progress display.
|
|
113
|
+
*
|
|
114
|
+
* @param {object} torrent
|
|
115
|
+
* @param {number | null} [fileIndex]
|
|
116
|
+
* @param {{ resumeAnchorByteStart?: number | null }} [options]
|
|
117
|
+
* @returns {Promise<object | null>}
|
|
118
|
+
*/
|
|
119
|
+
async getFileStats(torrent, fileIndex = null, options = {}) {
|
|
120
|
+
const sourceKey = torrent?.sourceKey;
|
|
121
|
+
if (!sourceKey) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
return this.#client.getFileStats({
|
|
125
|
+
sourceKey,
|
|
126
|
+
fileIndex,
|
|
127
|
+
resumeAnchorByteStart: options?.resumeAnchorByteStart ?? null
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Reorder piece selection around a read position.
|
|
133
|
+
*
|
|
134
|
+
* Synchronous by design — see the file header.
|
|
135
|
+
*
|
|
136
|
+
* @param {object} torrent
|
|
137
|
+
* @param {number} fileIndex
|
|
138
|
+
* @param {number} byteStart
|
|
139
|
+
* @param {number} [windowBytes]
|
|
140
|
+
* @returns {void}
|
|
141
|
+
*/
|
|
142
|
+
prioritizeByteRange(torrent, fileIndex, byteStart, windowBytes) {
|
|
143
|
+
const sourceKey = torrent?.sourceKey;
|
|
144
|
+
if (!sourceKey) {
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
void this.#client
|
|
148
|
+
.prioritizeByteRange({ sourceKey, fileIndex, byteStart, windowBytes })
|
|
149
|
+
.catch(() => undefined);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Pre-fetch the head and tail the codec probe needs.
|
|
154
|
+
*
|
|
155
|
+
* @param {object} torrent
|
|
156
|
+
* @param {number} fileIndex
|
|
157
|
+
* @param {number} [headBytes]
|
|
158
|
+
* @param {number} [tailBytes]
|
|
159
|
+
* @param {number} [timeoutMs]
|
|
160
|
+
* @returns {Promise<unknown>}
|
|
161
|
+
*/
|
|
162
|
+
async prefetchFileEdges(torrent, fileIndex, headBytes, tailBytes, timeoutMs) {
|
|
163
|
+
const sourceKey = torrent?.sourceKey;
|
|
164
|
+
if (!sourceKey) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
return this.#client.prefetchFileEdges({ sourceKey, fileIndex, headBytes, tailBytes, timeoutMs });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Shut the torrent client down and stop the thread.
|
|
172
|
+
*
|
|
173
|
+
* @returns {Promise<void>}
|
|
174
|
+
*/
|
|
175
|
+
async destroyAll() {
|
|
176
|
+
this.#torrents.clear();
|
|
177
|
+
await this.#client.destroyAll();
|
|
178
|
+
}
|
|
179
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file `webrtc-polyfill`'s interface, served by a pure-JavaScript WebRTC stack.
|
|
3
|
+
*
|
|
4
|
+
* **Why this exists.** node-datachannel is native, and its native side is not
|
|
5
|
+
* safe to use from two V8 isolates at once: the process aborts with
|
|
6
|
+
* `HandleScope: Entering the V8 API without proper locking in place`. Measured
|
|
7
|
+
* on win32/x64 and linux/arm64 alike — one isolate is fine (either the main
|
|
8
|
+
* thread or a worker), two at the same time is fatal, and preloading in both
|
|
9
|
+
* does not help. The upstream issue about workers (#129) was closed in 0.4.0
|
|
10
|
+
* but only covers use from a worker ALONE, which does work.
|
|
11
|
+
*
|
|
12
|
+
* We need it twice: `webrtc-manager.js` runs the video channel to the browser on
|
|
13
|
+
* the main thread, and the torrent client — which is now on its own thread —
|
|
14
|
+
* creates peer connections of its own to announce on `wss://` trackers. Before
|
|
15
|
+
* the thread split both lived in one isolate and nothing was wrong; afterwards
|
|
16
|
+
* any torrent carrying a wss tracker took the whole proxy down.
|
|
17
|
+
*
|
|
18
|
+
* So the native stack stays where it earns its keep — the main thread, carrying
|
|
19
|
+
* video — and the torrent's trackers get a JavaScript implementation, where the
|
|
20
|
+
* traffic is a handful of signalling messages. `services/torrent-worker/worker.js`
|
|
21
|
+
* installs a module resolution hook that points `webrtc-polyfill` here; nothing
|
|
22
|
+
* outside the worker thread is affected, and no dependency is patched (the addon
|
|
23
|
+
* installs with `--ignore-scripts`, so a postinstall patch would never run).
|
|
24
|
+
*
|
|
25
|
+
* **What the shim has to fix.** werift's peer connection matches the interface
|
|
26
|
+
* `simple-peer` expects, but its data channel differs in two ways that matter:
|
|
27
|
+
*
|
|
28
|
+
* - it has no `binaryType`, and hands `onmessage` a `Buffer`. `simple-peer`
|
|
29
|
+
* only recognises `ArrayBuffer`; anything else goes through `text2arr`,
|
|
30
|
+
* which would corrupt every byte of torrent payload.
|
|
31
|
+
* - it has no `onbufferedamountlow`. `simple-peer` builds its backpressure on
|
|
32
|
+
* that event, so without it a send that hits the high-water mark never
|
|
33
|
+
* resumes.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import {
|
|
37
|
+
RTCPeerConnection as WeriftPeerConnection,
|
|
38
|
+
RTCIceCandidate
|
|
39
|
+
} from "werift";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A session description built the way browsers build it — from one object.
|
|
43
|
+
*
|
|
44
|
+
* werift's own class takes two positional arguments, `(sdp, type)`. Callers
|
|
45
|
+
* written against the browser pass `{ type, sdp }`, so with werift's class the
|
|
46
|
+
* type lands in the sdp slot and the description is rejected: field 2026-08-03,
|
|
47
|
+
* wss announces succeeded but every peer connection died with "Connection
|
|
48
|
+
* error: invalid sessionDescription".
|
|
49
|
+
*
|
|
50
|
+
* `RTCIceCandidate` needs no such treatment — werift already takes an object.
|
|
51
|
+
*/
|
|
52
|
+
class ShimSessionDescription {
|
|
53
|
+
/**
|
|
54
|
+
* @param {{ type?: string, sdp?: string } | string} init
|
|
55
|
+
* @param {string} [type] - Tolerated for callers using werift's own order.
|
|
56
|
+
*/
|
|
57
|
+
constructor(init, type) {
|
|
58
|
+
if (typeof init === "string") {
|
|
59
|
+
this.sdp = init;
|
|
60
|
+
this.type = type;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
this.type = init?.type;
|
|
64
|
+
this.sdp = init?.sdp;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
toJSON() {
|
|
68
|
+
return { type: this.type, sdp: this.sdp };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A werift data channel wearing the browser's interface.
|
|
74
|
+
*
|
|
75
|
+
* Only what `simple-peer` touches is implemented — promising more would be
|
|
76
|
+
* pretending, since anything else has no caller and would never be exercised.
|
|
77
|
+
*/
|
|
78
|
+
class ShimDataChannel {
|
|
79
|
+
#channel;
|
|
80
|
+
/** @type {"blob" | "arraybuffer"} */
|
|
81
|
+
binaryType = "blob";
|
|
82
|
+
/** @type {((event: { data: ArrayBuffer | Buffer }) => void) | null} */
|
|
83
|
+
onmessage = null;
|
|
84
|
+
/** @type {(() => void) | null} */
|
|
85
|
+
onopen = null;
|
|
86
|
+
/** @type {(() => void) | null} */
|
|
87
|
+
onclose = null;
|
|
88
|
+
/** @type {((event: { error?: Error }) => void) | null} */
|
|
89
|
+
onerror = null;
|
|
90
|
+
/** @type {(() => void) | null} */
|
|
91
|
+
onbufferedamountlow = null;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {object} channel - werift's `RTCDataChannel`.
|
|
95
|
+
*/
|
|
96
|
+
constructor(channel) {
|
|
97
|
+
this.#channel = channel;
|
|
98
|
+
|
|
99
|
+
channel.onmessage = (event) => {
|
|
100
|
+
const data = event?.data ?? event;
|
|
101
|
+
this.onmessage?.({ data: this.#toWireFormat(data) });
|
|
102
|
+
};
|
|
103
|
+
channel.onopen = () => this.onopen?.();
|
|
104
|
+
channel.onclose = () => this.onclose?.();
|
|
105
|
+
channel.onerror = (event) => this.onerror?.(event ?? {});
|
|
106
|
+
|
|
107
|
+
// werift reports this as an observable rather than a handler property.
|
|
108
|
+
channel.bufferedAmountLow?.subscribe?.(() => this.onbufferedamountlow?.());
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Match what a browser would deliver for the requested `binaryType`.
|
|
113
|
+
*
|
|
114
|
+
* `simple-peer` checks `instanceof ArrayBuffer` and sends everything else
|
|
115
|
+
* through a text decoder, so a `Buffer` handed over unchanged arrives
|
|
116
|
+
* mangled.
|
|
117
|
+
*
|
|
118
|
+
* @param {unknown} data
|
|
119
|
+
* @returns {ArrayBuffer | unknown}
|
|
120
|
+
*/
|
|
121
|
+
#toWireFormat(data) {
|
|
122
|
+
if (this.binaryType !== "arraybuffer" || typeof data === "string") {
|
|
123
|
+
return data;
|
|
124
|
+
}
|
|
125
|
+
if (ArrayBuffer.isView(data)) {
|
|
126
|
+
return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
|
|
127
|
+
}
|
|
128
|
+
return data;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
get label() {
|
|
132
|
+
return this.#channel.label;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
get readyState() {
|
|
136
|
+
return this.#channel.readyState;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
get bufferedAmount() {
|
|
140
|
+
return this.#channel.bufferedAmount;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
get bufferedAmountLowThreshold() {
|
|
144
|
+
return this.#channel.bufferedAmountLowThreshold;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
set bufferedAmountLowThreshold(value) {
|
|
148
|
+
this.#channel.bufferedAmountLowThreshold = value;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {string | ArrayBuffer | ArrayBufferView} data
|
|
153
|
+
* @returns {void}
|
|
154
|
+
*/
|
|
155
|
+
send(data) {
|
|
156
|
+
if (typeof data === "string") {
|
|
157
|
+
this.#channel.send(data);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
this.#channel.send(ArrayBuffer.isView(data) ? Buffer.from(data.buffer, data.byteOffset, data.byteLength) : Buffer.from(data));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
close() {
|
|
164
|
+
this.#channel.close();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* werift's peer connection, handing out data channels that carry the browser's
|
|
170
|
+
* interface. Everything else is inherited unchanged — the peer connection side
|
|
171
|
+
* already matches.
|
|
172
|
+
*/
|
|
173
|
+
class ShimPeerConnection extends WeriftPeerConnection {
|
|
174
|
+
/**
|
|
175
|
+
* @param {...unknown} args - Passed through to werift.
|
|
176
|
+
*/
|
|
177
|
+
constructor(...args) {
|
|
178
|
+
super(...args);
|
|
179
|
+
|
|
180
|
+
// `ondatachannel` has to be redefined on the instance, not declared as an
|
|
181
|
+
// accessor on this class: werift assigns it as an own field in its own
|
|
182
|
+
// constructor, and an own property shadows a prototype accessor — so a
|
|
183
|
+
// subclass setter is simply never called, and the caller receives werift's
|
|
184
|
+
// bare channel instead of the wrapped one.
|
|
185
|
+
let handler = null;
|
|
186
|
+
const deliver = (event) => {
|
|
187
|
+
handler?.({ ...event, channel: new ShimDataChannel(event?.channel ?? event) });
|
|
188
|
+
};
|
|
189
|
+
Object.defineProperty(this, "ondatachannel", {
|
|
190
|
+
configurable: true,
|
|
191
|
+
// werift reads this property to dispatch, so it must hand back the
|
|
192
|
+
// wrapper rather than what the caller assigned.
|
|
193
|
+
get: () => (handler ? deliver : null),
|
|
194
|
+
set: (value) => {
|
|
195
|
+
handler = value;
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* @param {string} label
|
|
202
|
+
* @param {object} [options]
|
|
203
|
+
* @returns {ShimDataChannel}
|
|
204
|
+
*/
|
|
205
|
+
createDataChannel(label, options) {
|
|
206
|
+
return new ShimDataChannel(super.createDataChannel(label, options));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export {
|
|
211
|
+
ShimPeerConnection as RTCPeerConnection,
|
|
212
|
+
ShimSessionDescription as RTCSessionDescription,
|
|
213
|
+
RTCIceCandidate
|
|
214
|
+
};
|
|
215
|
+
export default {
|
|
216
|
+
RTCPeerConnection: ShimPeerConnection,
|
|
217
|
+
RTCSessionDescription: ShimSessionDescription,
|
|
218
|
+
RTCIceCandidate
|
|
219
|
+
};
|
|
@@ -17,12 +17,27 @@
|
|
|
17
17
|
* threads.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
// MUST stay first: it redirects `webrtc-polyfill` to a JavaScript WebRTC stack
|
|
21
|
+
// before WebTorrent can reach the native one. Two isolates using
|
|
22
|
+
// node-datachannel at once abort the process, and the torrent's wss trackers
|
|
23
|
+
// create peer connections of their own.
|
|
24
|
+
import "./install-webrtc-shim.js";
|
|
20
25
|
import { parentPort, workerData } from "node:worker_threads";
|
|
21
|
-
import { TorrentPool } from "../torrent-pool.js";
|
|
22
26
|
import { createSendStream } from "./channel.js";
|
|
23
27
|
import { Command, Event, STREAM_CHUNK_BYTES } from "./protocol.js";
|
|
24
28
|
|
|
25
|
-
|
|
29
|
+
// Imported dynamically, and that is load-bearing: static imports are RESOLVED
|
|
30
|
+
// during linking, before any module body runs, so a statically imported pool
|
|
31
|
+
// would drag in WebTorrent — and with it the real `webrtc-polyfill` — before
|
|
32
|
+
// the hook above had a chance to register. Verified the hard way: with a static
|
|
33
|
+
// import the process still aborted, and the stack named the genuine polyfill.
|
|
34
|
+
const { TorrentPool } = await import("../torrent-pool.js");
|
|
35
|
+
const { collectStoreStats } = await import("../piece-store/shared-piece-store.js");
|
|
36
|
+
|
|
37
|
+
const pool = new TorrentPool({
|
|
38
|
+
maxDiskBytes: workerData?.maxDiskBytes,
|
|
39
|
+
memoryBytes: workerData?.memoryBytes
|
|
40
|
+
});
|
|
26
41
|
|
|
27
42
|
/** Torrents by sourceKey — the main thread names them, this thread owns them. */
|
|
28
43
|
const torrentsByKey = new Map();
|
|
@@ -262,4 +277,36 @@ parentPort.on("message", async (message) => {
|
|
|
262
277
|
}
|
|
263
278
|
});
|
|
264
279
|
|
|
280
|
+
/**
|
|
281
|
+
* How often the piece store reports what it has been doing.
|
|
282
|
+
*
|
|
283
|
+
* The store decides whether a read costs nothing or costs a disk trip, and
|
|
284
|
+
* until 2.9.75 nothing about it reached the log — a field oddity would have had
|
|
285
|
+
* no evidence to work from. Reported only when something changed, so an idle
|
|
286
|
+
* proxy stays quiet.
|
|
287
|
+
*/
|
|
288
|
+
const STORE_REPORT_INTERVAL_MS = 60_000;
|
|
289
|
+
|
|
290
|
+
/** Last reported figures per store, so unchanged ones stay silent. */
|
|
291
|
+
const lastReported = new Map();
|
|
292
|
+
|
|
293
|
+
setInterval(() => {
|
|
294
|
+
for (const stats of collectStoreStats()) {
|
|
295
|
+
const signature = `${stats.fromMemory}/${stats.fromDisk}/${stats.spills}/${stats.revivals}/${stats.blockedByPins}`;
|
|
296
|
+
if (lastReported.get(stats.name) === signature) {
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
lastReported.set(stats.name, signature);
|
|
300
|
+
|
|
301
|
+
const reads = stats.fromMemory + stats.fromDisk;
|
|
302
|
+
const fromMemoryShare = reads > 0 ? ((stats.fromMemory / reads) * 100).toFixed(1) : "—";
|
|
303
|
+
log(
|
|
304
|
+
`piece-store "${stats.name.slice(0, 40)}": resident=${stats.resident}/${stats.capacity} ` +
|
|
305
|
+
`spilled=${stats.spilled} reads=${reads} (${fromMemoryShare}% from memory) ` +
|
|
306
|
+
`spills=${stats.spills} revivals=${stats.revivals}` +
|
|
307
|
+
(stats.blockedByPins > 0 ? ` blocked-by-pins=${stats.blockedByPins}` : "")
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}, STORE_REPORT_INTERVAL_MS).unref();
|
|
311
|
+
|
|
265
312
|
log("torrent worker started");
|