torrent-tui 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +156 -0
- package/bin/torrent-tui +26 -0
- package/package.json +58 -0
- package/src/app.ts +157 -0
- package/src/config/index.ts +41 -0
- package/src/config/settings.ts +15 -0
- package/src/constants/index.ts +16 -0
- package/src/controllers/app-controller.ts +180 -0
- package/src/index.ts +320 -0
- package/src/layout/add-torrent-dialog.ts +170 -0
- package/src/layout/confirm-dialog.ts +141 -0
- package/src/layout/content-window.ts +80 -0
- package/src/layout/sidebar.ts +121 -0
- package/src/layout/status-bar.ts +79 -0
- package/src/layout/toast-manager.ts +109 -0
- package/src/layout/toast.ts +257 -0
- package/src/layout/torrent-view.ts +250 -0
- package/src/store/index.ts +51 -0
- package/src/theme/default.ts +22 -0
- package/src/theme/index.ts +19 -0
- package/src/theme/types.ts +26 -0
- package/src/torrent/bridge.ts +301 -0
- package/src/torrent/downloader.ts +415 -0
- package/src/torrent/get_peers.ts +212 -0
- package/src/torrent/metadata.ts +190 -0
- package/src/torrent/parser.ts +216 -0
- package/src/torrent/peer/connection.ts +278 -0
- package/src/torrent/peer/handshake.ts +48 -0
- package/src/torrent/peer/listener.ts +52 -0
- package/src/torrent/peer/manager.ts +233 -0
- package/src/torrent/peer/message-buffer.ts +31 -0
- package/src/torrent/peer/peer-id.ts +21 -0
- package/src/torrent/peer/protocol.ts +123 -0
- package/src/torrent/piece-picker.ts +58 -0
- package/src/torrent/session.ts +56 -0
- package/src/torrent/storage.ts +197 -0
- package/src/torrent/tracker/announce.ts +36 -0
- package/src/torrent/tracker/http-tracker.ts +143 -0
- package/src/torrent/tracker/udp-tracker.ts +136 -0
- package/src/torrent/types.ts +25 -0
- package/src/types/layout.ts +6 -0
- package/src/utils/env.ts +8 -0
- package/src/utils/filter.ts +12 -0
- package/src/utils/layout.ts +32 -0
- package/src/utils/paths.ts +17 -0
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
const PROTOCOL = "BitTorrent protocol";
|
|
2
|
+
const PROTOCOL_BYTES = new TextEncoder().encode(PROTOCOL);
|
|
3
|
+
const HANDSHAKE_LEN = 68;
|
|
4
|
+
|
|
5
|
+
export function buildHandshake(
|
|
6
|
+
infoHash: Uint8Array,
|
|
7
|
+
peerId: Uint8Array,
|
|
8
|
+
): Uint8Array {
|
|
9
|
+
const buf = new Uint8Array(HANDSHAKE_LEN);
|
|
10
|
+
buf[0] = 19; // pstrlen
|
|
11
|
+
buf.set(PROTOCOL_BYTES, 1); // pstr (19 bytes)
|
|
12
|
+
// bytes 20–27: reserved (zeros, already set)
|
|
13
|
+
buf.set(infoHash, 28); // info_hash
|
|
14
|
+
buf.set(peerId, 48); // peer_id
|
|
15
|
+
return buf;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function parseHandshake(
|
|
19
|
+
data: Uint8Array,
|
|
20
|
+
expectedInfoHash: Uint8Array,
|
|
21
|
+
): { peerId: string; reserved: Uint8Array } {
|
|
22
|
+
if (data.length < HANDSHAKE_LEN) {
|
|
23
|
+
throw new Error(`Handshake too short: ${data.length} bytes`);
|
|
24
|
+
}
|
|
25
|
+
if (data[0] !== 19) {
|
|
26
|
+
throw new Error(`Invalid pstrlen: ${data[0]}`);
|
|
27
|
+
}
|
|
28
|
+
const pstr = new TextDecoder().decode(data.slice(1, 20));
|
|
29
|
+
if (pstr !== PROTOCOL) {
|
|
30
|
+
throw new Error(`Unknown protocol: ${pstr}`);
|
|
31
|
+
}
|
|
32
|
+
const reserved = data.slice(20, 28);
|
|
33
|
+
const infoHash = data.slice(28, 48);
|
|
34
|
+
const peerId = data.slice(48, 68);
|
|
35
|
+
|
|
36
|
+
for (let i = 0; i < 20; i++) {
|
|
37
|
+
if (infoHash[i] !== expectedInfoHash[i]) {
|
|
38
|
+
throw new Error("info_hash mismatch");
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
peerId: Buffer.from(peerId).toString("latin1"),
|
|
44
|
+
reserved,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export { HANDSHAKE_LEN };
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { createServer } from "node:net";
|
|
2
|
+
import type { Server, Socket } from "node:net";
|
|
3
|
+
import { log } from "../metadata.ts";
|
|
4
|
+
|
|
5
|
+
const PORT_RANGE = [6881, 6882, 6883, 6884, 6885, 6886, 6887, 6888, 6889];
|
|
6
|
+
|
|
7
|
+
export class PeerListener {
|
|
8
|
+
port: number = 0;
|
|
9
|
+
private server: Server | null = null;
|
|
10
|
+
private connectionCb: ((socket: Socket) => void) | null = null;
|
|
11
|
+
|
|
12
|
+
async listen(): Promise<void> {
|
|
13
|
+
for (const p of PORT_RANGE) {
|
|
14
|
+
const success = await this.tryListen(p);
|
|
15
|
+
if (success) {
|
|
16
|
+
this.port = p;
|
|
17
|
+
log("listener", `port ${p}`);
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
throw new Error("No available port in range 6881–6889");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
private tryListen(port: number): Promise<boolean> {
|
|
25
|
+
return new Promise((resolve) => {
|
|
26
|
+
const server = createServer((socket) => {
|
|
27
|
+
const remote = `${socket.remoteAddress}:${socket.remotePort}`;
|
|
28
|
+
log("inbound", remote);
|
|
29
|
+
this.connectionCb?.(socket);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
server.once("listening", () => {
|
|
33
|
+
this.server = server;
|
|
34
|
+
resolve(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
server.once("error", () => {
|
|
38
|
+
resolve(false);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
server.listen(port);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
onConnection(cb: (socket: Socket) => void): void {
|
|
46
|
+
this.connectionCb = cb;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
close(): void {
|
|
50
|
+
this.server?.close();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { log } from "../metadata.ts";
|
|
3
|
+
import { PeerConnection } from "./connection.ts";
|
|
4
|
+
import { PeerListener } from "./listener.ts";
|
|
5
|
+
import { buildHandshake, parseHandshake, HANDSHAKE_LEN } from "./handshake.ts";
|
|
6
|
+
import { getPeerId } from "./peer-id.ts";
|
|
7
|
+
import type { TorrentMetadata } from "../metadata.ts";
|
|
8
|
+
import type { PeerInfo } from "../types.ts";
|
|
9
|
+
|
|
10
|
+
export class PeerManager extends EventEmitter {
|
|
11
|
+
readonly connections: Map<string, PeerConnection> = new Map();
|
|
12
|
+
private listener: PeerListener;
|
|
13
|
+
private maxConnections: number;
|
|
14
|
+
private infoHash: Uint8Array;
|
|
15
|
+
private pieceCount: number;
|
|
16
|
+
private chokeTimer: ReturnType<typeof setInterval> | null = null;
|
|
17
|
+
private optimisticTimer: ReturnType<typeof setInterval> | null = null;
|
|
18
|
+
private optimisticKey: string | null = null;
|
|
19
|
+
private unchokedKeys = new Set<string>();
|
|
20
|
+
|
|
21
|
+
constructor(metadata: TorrentMetadata, maxConnections = 50) {
|
|
22
|
+
super();
|
|
23
|
+
this.infoHash = metadata.infoHash;
|
|
24
|
+
this.pieceCount = metadata.pieceCount;
|
|
25
|
+
this.maxConnections = maxConnections;
|
|
26
|
+
this.listener = new PeerListener();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async start(): Promise<void> {
|
|
30
|
+
await this.listener.listen();
|
|
31
|
+
this.listener.onConnection((socket) => this.handleInbound(socket));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async connect(peers: PeerInfo[]): Promise<void> {
|
|
35
|
+
const unique = this.dedup(peers);
|
|
36
|
+
const toConnect = unique.slice(
|
|
37
|
+
0,
|
|
38
|
+
Math.max(0, this.maxConnections - this.connections.size),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
await Promise.allSettled(
|
|
42
|
+
toConnect.map((p) => this.connectOne(p.ip, p.port)),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private async connectOne(ip: string, port: number): Promise<void> {
|
|
47
|
+
const key = `${ip}:${port}`;
|
|
48
|
+
if (this.connections.has(key)) return;
|
|
49
|
+
|
|
50
|
+
const conn = new PeerConnection(ip, port, this.infoHash);
|
|
51
|
+
|
|
52
|
+
conn.on("bitfield", () => {
|
|
53
|
+
if (conn.countPiecesPublic() > 0 && !conn.amInterested) conn.sendInterested();
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
conn.on("disconnect", () => {
|
|
57
|
+
this.connections.delete(key);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
await conn.connect();
|
|
62
|
+
this.connections.set(key, conn);
|
|
63
|
+
this.emit("peerAdded", conn);
|
|
64
|
+
} catch {
|
|
65
|
+
// timeout/error already logged by connection.ts
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private handleInbound(socket: import("node:net").Socket): void {
|
|
70
|
+
let buf = new Uint8Array(0);
|
|
71
|
+
|
|
72
|
+
const onData = (chunk: Buffer) => {
|
|
73
|
+
const merged = new Uint8Array(buf.length + chunk.length);
|
|
74
|
+
merged.set(buf);
|
|
75
|
+
merged.set(new Uint8Array(chunk), buf.length);
|
|
76
|
+
buf = merged;
|
|
77
|
+
|
|
78
|
+
if (buf.length < HANDSHAKE_LEN) return;
|
|
79
|
+
|
|
80
|
+
socket.removeListener("data", onData);
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const result = parseHandshake(buf, this.infoHash);
|
|
84
|
+
socket.write(buildHandshake(this.infoHash, getPeerId()));
|
|
85
|
+
|
|
86
|
+
const ip = socket.remoteAddress ?? "unknown";
|
|
87
|
+
const port = socket.remotePort ?? 0;
|
|
88
|
+
const key = `${ip}:${port}`;
|
|
89
|
+
log("peer", `${key.padEnd(50)} ok ${result.peerId.slice(0, 8)} (inbound)`);
|
|
90
|
+
|
|
91
|
+
// Wrap the existing socket in a PeerConnection
|
|
92
|
+
const conn = new PeerConnection(ip, port, this.infoHash);
|
|
93
|
+
// Hand off the already-connected socket by replaying the remainder
|
|
94
|
+
(conn as unknown as { socket: typeof socket }).socket = socket;
|
|
95
|
+
(conn as unknown as { handshakeDone: boolean }).handshakeDone = true;
|
|
96
|
+
(conn as unknown as { peerId: string }).peerId = result.peerId;
|
|
97
|
+
|
|
98
|
+
const remainder = buf.slice(HANDSHAKE_LEN);
|
|
99
|
+
if (remainder.length > 0) {
|
|
100
|
+
socket.emit("data", Buffer.from(remainder));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
socket.on("data", (c: Buffer) => socket.emit("data", c));
|
|
104
|
+
this.connections.set(key, conn);
|
|
105
|
+
} catch (err) {
|
|
106
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
107
|
+
log("handshake", `FAIL (inbound) ${msg}`);
|
|
108
|
+
socket.destroy();
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
socket.on("data", onData);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
getUnchoked(): PeerConnection[] {
|
|
116
|
+
return [...this.connections.values()].filter((c) => !c.amChoked);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
startChoking(): void {
|
|
120
|
+
// BEP 3: recalculate every 10s, optimistic rotates every 30s
|
|
121
|
+
this.chokeTimer = setInterval(() => this.recalculateChokes(), 10_000);
|
|
122
|
+
this.optimisticTimer = setInterval(() => this.rotateOptimisticUnchoke(), 30_000);
|
|
123
|
+
// Run immediately so peers are unchoked on download start
|
|
124
|
+
this.recalculateChokes();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
stopChoking(): void {
|
|
128
|
+
if (this.chokeTimer) clearInterval(this.chokeTimer);
|
|
129
|
+
if (this.optimisticTimer) clearInterval(this.optimisticTimer);
|
|
130
|
+
this.chokeTimer = null;
|
|
131
|
+
this.optimisticTimer = null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private recalculateChokes(): void {
|
|
135
|
+
const peers = [...this.connections.values()];
|
|
136
|
+
|
|
137
|
+
// Snapshot rates and reset interval counters
|
|
138
|
+
for (const p of peers) {
|
|
139
|
+
p.downloadBytesPerSec = p.downloadedThisInterval;
|
|
140
|
+
p.uploadBytesPerSec = p.uploadedThisInterval;
|
|
141
|
+
p.downloadedThisInterval = 0;
|
|
142
|
+
p.uploadedThisInterval = 0;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// BEP 3: unchoke top 4 by download rate from them (reciprocation)
|
|
146
|
+
const interested = peers.filter((p) => p.peerInterested);
|
|
147
|
+
const sorted = [...interested].sort((a, b) => b.downloadBytesPerSec - a.downloadBytesPerSec);
|
|
148
|
+
const toUnchoke = new Set<string>();
|
|
149
|
+
|
|
150
|
+
for (let i = 0; i < Math.min(4, sorted.length); i++) {
|
|
151
|
+
const p = sorted[i];
|
|
152
|
+
if (p) toUnchoke.add(`${p.address}:${p.port}`);
|
|
153
|
+
}
|
|
154
|
+
if (this.optimisticKey) toUnchoke.add(this.optimisticKey);
|
|
155
|
+
|
|
156
|
+
// Apply choke/unchoke changes
|
|
157
|
+
for (const p of peers) {
|
|
158
|
+
const key = `${p.address}:${p.port}`;
|
|
159
|
+
const shouldUnchoke = toUnchoke.has(key);
|
|
160
|
+
const wasUnchoked = this.unchokedKeys.has(key);
|
|
161
|
+
if (shouldUnchoke && !wasUnchoked) p.sendUnchoke();
|
|
162
|
+
else if (!shouldUnchoke && wasUnchoked) p.sendChoke();
|
|
163
|
+
}
|
|
164
|
+
this.unchokedKeys = toUnchoke;
|
|
165
|
+
|
|
166
|
+
if (toUnchoke.size > 0) {
|
|
167
|
+
const labels = [...toUnchoke]
|
|
168
|
+
.slice(0, 4)
|
|
169
|
+
.map((k) => {
|
|
170
|
+
const c = this.connections.get(k);
|
|
171
|
+
const mbps = ((c?.downloadBytesPerSec ?? 0) / (1024 * 1024)).toFixed(1);
|
|
172
|
+
return `${c?.peerId.slice(0, 8) ?? k}(${mbps})`;
|
|
173
|
+
})
|
|
174
|
+
.join(" ");
|
|
175
|
+
const optLabel = this.optimisticKey
|
|
176
|
+
? ` opt: ${this.connections.get(this.optimisticKey)?.peerId.slice(0, 8) ?? this.optimisticKey}`
|
|
177
|
+
: "";
|
|
178
|
+
log("unchoke", `${labels}${optLabel}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private rotateOptimisticUnchoke(): void {
|
|
183
|
+
const choked = [...this.connections.values()].filter(
|
|
184
|
+
(p) => !this.unchokedKeys.has(`${p.address}:${p.port}`) && p.peerInterested,
|
|
185
|
+
);
|
|
186
|
+
if (choked.length === 0) return;
|
|
187
|
+
|
|
188
|
+
// BEP 3: new connections are 3× as likely — simplified: just pick random
|
|
189
|
+
const pick = choked[Math.floor(Math.random() * choked.length)];
|
|
190
|
+
if (!pick) return;
|
|
191
|
+
|
|
192
|
+
const key = `${pick.address}:${pick.port}`;
|
|
193
|
+
if (this.optimisticKey && this.optimisticKey !== key) {
|
|
194
|
+
// Choke the previous optimistic peer if not in regular slots
|
|
195
|
+
if (!this.unchokedKeys.has(this.optimisticKey)) {
|
|
196
|
+
this.connections.get(this.optimisticKey)?.sendChoke();
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
this.optimisticKey = key;
|
|
200
|
+
pick.sendUnchoke();
|
|
201
|
+
this.unchokedKeys.add(key);
|
|
202
|
+
log("unchoke-opt", `${pick.peerId.slice(0, 8)} (optimistic slot)`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private dedup(peers: PeerInfo[]): PeerInfo[] {
|
|
206
|
+
const seen = new Set<string>();
|
|
207
|
+
return peers.filter((p) => {
|
|
208
|
+
const k = `${p.ip}:${p.port}`;
|
|
209
|
+
if (seen.has(k)) return false;
|
|
210
|
+
seen.add(k);
|
|
211
|
+
return true;
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private countBits(buf: Uint8Array): number {
|
|
216
|
+
let n = 0;
|
|
217
|
+
for (const byte of buf) {
|
|
218
|
+
let b = byte;
|
|
219
|
+
while (b) { n += b & 1; b >>>= 1; }
|
|
220
|
+
}
|
|
221
|
+
return n;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
close(): void {
|
|
225
|
+
this.stopChoking();
|
|
226
|
+
for (const conn of this.connections.values()) {
|
|
227
|
+
conn.suppressDisconnect = true;
|
|
228
|
+
conn.destroy();
|
|
229
|
+
}
|
|
230
|
+
this.connections.clear();
|
|
231
|
+
this.listener.close();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export class MessageBuffer {
|
|
2
|
+
private buf = new Uint8Array(0);
|
|
3
|
+
|
|
4
|
+
push(chunk: Uint8Array): Uint8Array[] {
|
|
5
|
+
// Append chunk to internal buffer
|
|
6
|
+
const merged = new Uint8Array(this.buf.length + chunk.length);
|
|
7
|
+
merged.set(this.buf);
|
|
8
|
+
merged.set(chunk, this.buf.length);
|
|
9
|
+
this.buf = merged;
|
|
10
|
+
|
|
11
|
+
const messages: Uint8Array[] = [];
|
|
12
|
+
|
|
13
|
+
while (this.buf.length >= 4) {
|
|
14
|
+
const length =
|
|
15
|
+
((this.buf[0] ?? 0) << 24) |
|
|
16
|
+
((this.buf[1] ?? 0) << 16) |
|
|
17
|
+
((this.buf[2] ?? 0) << 8) |
|
|
18
|
+
(this.buf[3] ?? 0);
|
|
19
|
+
|
|
20
|
+
// length=0 is a keepalive — complete message is just the 4 length bytes
|
|
21
|
+
const totalLen = 4 + length;
|
|
22
|
+
|
|
23
|
+
if (this.buf.length < totalLen) break;
|
|
24
|
+
|
|
25
|
+
messages.push(this.buf.slice(0, totalLen));
|
|
26
|
+
this.buf = this.buf.slice(totalLen);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return messages;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const PEER_ID_PREFIX = "-TT0001-";
|
|
4
|
+
|
|
5
|
+
let _peerId: Uint8Array | null = null;
|
|
6
|
+
|
|
7
|
+
export function getPeerId(): Uint8Array {
|
|
8
|
+
if (!_peerId) {
|
|
9
|
+
const prefix = Buffer.from(PEER_ID_PREFIX);
|
|
10
|
+
const random = randomBytes(20 - prefix.length);
|
|
11
|
+
_peerId = new Uint8Array(Buffer.concat([prefix, random]));
|
|
12
|
+
}
|
|
13
|
+
return _peerId;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function peerIdToString(id: Uint8Array): string {
|
|
17
|
+
// Show ASCII prefix + hex for the random portion
|
|
18
|
+
const prefix = Buffer.from(id.slice(0, 8)).toString("ascii");
|
|
19
|
+
const hex = Buffer.from(id.slice(8)).toString("hex").slice(0, 12);
|
|
20
|
+
return `${prefix}${hex}...`;
|
|
21
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
export const MSG = {
|
|
2
|
+
KEEPALIVE: -1,
|
|
3
|
+
CHOKE: 0,
|
|
4
|
+
UNCHOKE: 1,
|
|
5
|
+
INTERESTED: 2,
|
|
6
|
+
NOT_INTERESTED: 3,
|
|
7
|
+
HAVE: 4,
|
|
8
|
+
BITFIELD: 5,
|
|
9
|
+
REQUEST: 6,
|
|
10
|
+
PIECE: 7,
|
|
11
|
+
CANCEL: 8,
|
|
12
|
+
} as const;
|
|
13
|
+
|
|
14
|
+
export type MessageType = (typeof MSG)[keyof typeof MSG];
|
|
15
|
+
|
|
16
|
+
export interface PeerMessage {
|
|
17
|
+
type: MessageType;
|
|
18
|
+
payload?: Uint8Array;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function encode(msg: PeerMessage): Uint8Array {
|
|
22
|
+
if (msg.type === MSG.KEEPALIVE) {
|
|
23
|
+
return new Uint8Array(4); // 4 zero bytes
|
|
24
|
+
}
|
|
25
|
+
const payload = msg.payload ?? new Uint8Array(0);
|
|
26
|
+
const buf = new Uint8Array(4 + 1 + payload.length);
|
|
27
|
+
const len = 1 + payload.length;
|
|
28
|
+
buf[0] = (len >>> 24) & 0xff;
|
|
29
|
+
buf[1] = (len >>> 16) & 0xff;
|
|
30
|
+
buf[2] = (len >>> 8) & 0xff;
|
|
31
|
+
buf[3] = len & 0xff;
|
|
32
|
+
buf[4] = msg.type as number;
|
|
33
|
+
buf.set(payload, 5);
|
|
34
|
+
return buf;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function decode(raw: Uint8Array): PeerMessage {
|
|
38
|
+
const length =
|
|
39
|
+
((raw[0] ?? 0) << 24) |
|
|
40
|
+
((raw[1] ?? 0) << 16) |
|
|
41
|
+
((raw[2] ?? 0) << 8) |
|
|
42
|
+
(raw[3] ?? 0);
|
|
43
|
+
|
|
44
|
+
if (length === 0) return { type: MSG.KEEPALIVE };
|
|
45
|
+
|
|
46
|
+
const id = raw[4] as MessageType;
|
|
47
|
+
const payload = raw.length > 5 ? raw.slice(5) : undefined;
|
|
48
|
+
return { type: id, payload };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Helpers to build specific message payloads
|
|
52
|
+
|
|
53
|
+
export function encodeHave(pieceIndex: number): Uint8Array {
|
|
54
|
+
const payload = new Uint8Array(4);
|
|
55
|
+
payload[0] = (pieceIndex >>> 24) & 0xff;
|
|
56
|
+
payload[1] = (pieceIndex >>> 16) & 0xff;
|
|
57
|
+
payload[2] = (pieceIndex >>> 8) & 0xff;
|
|
58
|
+
payload[3] = pieceIndex & 0xff;
|
|
59
|
+
return encode({ type: MSG.HAVE, payload });
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function encodeRequest(
|
|
63
|
+
index: number,
|
|
64
|
+
begin: number,
|
|
65
|
+
length: number,
|
|
66
|
+
): Uint8Array {
|
|
67
|
+
const payload = new Uint8Array(12);
|
|
68
|
+
const view = new DataView(payload.buffer);
|
|
69
|
+
view.setUint32(0, index);
|
|
70
|
+
view.setUint32(4, begin);
|
|
71
|
+
view.setUint32(8, length);
|
|
72
|
+
return encode({ type: MSG.REQUEST, payload });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function encodeCancel(
|
|
76
|
+
index: number,
|
|
77
|
+
begin: number,
|
|
78
|
+
length: number,
|
|
79
|
+
): Uint8Array {
|
|
80
|
+
const payload = new Uint8Array(12);
|
|
81
|
+
const view = new DataView(payload.buffer);
|
|
82
|
+
view.setUint32(0, index);
|
|
83
|
+
view.setUint32(4, begin);
|
|
84
|
+
view.setUint32(8, length);
|
|
85
|
+
return encode({ type: MSG.CANCEL, payload });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function decodeHave(payload: Uint8Array): number {
|
|
89
|
+
return new DataView(payload.buffer, payload.byteOffset).getUint32(0);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function decodeRequest(payload: Uint8Array): {
|
|
93
|
+
index: number;
|
|
94
|
+
begin: number;
|
|
95
|
+
length: number;
|
|
96
|
+
} {
|
|
97
|
+
const view = new DataView(payload.buffer, payload.byteOffset);
|
|
98
|
+
return {
|
|
99
|
+
index: view.getUint32(0),
|
|
100
|
+
begin: view.getUint32(4),
|
|
101
|
+
length: view.getUint32(8),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function decodePiece(payload: Uint8Array): {
|
|
106
|
+
index: number;
|
|
107
|
+
begin: number;
|
|
108
|
+
block: Uint8Array;
|
|
109
|
+
} {
|
|
110
|
+
const view = new DataView(payload.buffer, payload.byteOffset);
|
|
111
|
+
return {
|
|
112
|
+
index: view.getUint32(0),
|
|
113
|
+
begin: view.getUint32(4),
|
|
114
|
+
block: payload.slice(8),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function msgName(type: MessageType): string {
|
|
119
|
+
for (const [k, v] of Object.entries(MSG)) {
|
|
120
|
+
if (v === type) return k;
|
|
121
|
+
}
|
|
122
|
+
return `UNKNOWN(${type})`;
|
|
123
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { PeerConnection } from "./peer/connection.ts";
|
|
2
|
+
|
|
3
|
+
export class PiecePicker {
|
|
4
|
+
private availability = new Map<number, number>(); // pieceIndex → peer count
|
|
5
|
+
|
|
6
|
+
constructor(
|
|
7
|
+
private pieceCount: number,
|
|
8
|
+
private hasPiece: (i: number) => boolean,
|
|
9
|
+
private isInProgress: (i: number) => boolean,
|
|
10
|
+
) {}
|
|
11
|
+
|
|
12
|
+
addPeer(conn: PeerConnection): void {
|
|
13
|
+
for (let i = 0; i < this.pieceCount; i++) {
|
|
14
|
+
if (conn.hasPiece(i)) {
|
|
15
|
+
this.availability.set(i, (this.availability.get(i) ?? 0) + 1);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
removePeer(conn: PeerConnection): void {
|
|
21
|
+
for (let i = 0; i < this.pieceCount; i++) {
|
|
22
|
+
if (conn.hasPiece(i)) {
|
|
23
|
+
const n = (this.availability.get(i) ?? 1) - 1;
|
|
24
|
+
if (n <= 0) this.availability.delete(i);
|
|
25
|
+
else this.availability.set(i, n);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
onHave(pieceIndex: number): void {
|
|
31
|
+
this.availability.set(pieceIndex, (this.availability.get(pieceIndex) ?? 0) + 1);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Returns the lowest-availability unstarted piece this peer has that we need.
|
|
35
|
+
// In-progress pieces are handled by Tier 1 in nextBlock() — skip them here.
|
|
36
|
+
pick(conn: PeerConnection): number | null {
|
|
37
|
+
let best = -1;
|
|
38
|
+
let bestAvail = Infinity;
|
|
39
|
+
|
|
40
|
+
for (let i = 0; i < this.pieceCount; i++) {
|
|
41
|
+
if (this.hasPiece(i)) continue;
|
|
42
|
+
if (this.isInProgress(i)) continue; // Tier 1 handles these
|
|
43
|
+
if (!conn.hasPiece(i)) continue;
|
|
44
|
+
|
|
45
|
+
const avail = this.availability.get(i) ?? 1;
|
|
46
|
+
if (avail < bestAvail) {
|
|
47
|
+
bestAvail = avail;
|
|
48
|
+
best = i;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return best === -1 ? null : best;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
availabilityOf(pieceIndex: number): number {
|
|
56
|
+
return this.availability.get(pieceIndex) ?? 0;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { TorrentMetadata, log } from "./metadata.ts";
|
|
3
|
+
import { StorageManager } from "./storage.ts";
|
|
4
|
+
import { Downloader } from "./downloader.ts";
|
|
5
|
+
import type { TorrentStatus } from "./types.ts";
|
|
6
|
+
import type { PeerManager } from "./peer/manager.ts";
|
|
7
|
+
|
|
8
|
+
export class TorrentSession extends EventEmitter {
|
|
9
|
+
readonly metadata: TorrentMetadata;
|
|
10
|
+
readonly storage: StorageManager;
|
|
11
|
+
readonly downloadPath: string;
|
|
12
|
+
status: TorrentStatus = "created";
|
|
13
|
+
|
|
14
|
+
constructor(metadata: TorrentMetadata, downloadPath: string) {
|
|
15
|
+
super();
|
|
16
|
+
this.metadata = metadata;
|
|
17
|
+
this.downloadPath = downloadPath;
|
|
18
|
+
this.storage = new StorageManager(metadata, downloadPath);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
private transition(next: TorrentStatus): void {
|
|
22
|
+
const prev = this.status;
|
|
23
|
+
this.status = next;
|
|
24
|
+
this.emit("status", next, prev);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async start(): Promise<void> {
|
|
28
|
+
this.transition("verifying");
|
|
29
|
+
await this.storage.setup();
|
|
30
|
+
await this.storage.verifyAll();
|
|
31
|
+
this.transition("ready");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
download(manager: PeerManager): Downloader {
|
|
35
|
+
this.transition("downloading");
|
|
36
|
+
const downloader = new Downloader(
|
|
37
|
+
this.metadata,
|
|
38
|
+
this.storage,
|
|
39
|
+
manager,
|
|
40
|
+
this.downloadPath,
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
downloader.on("piece:verified", (i: number) => this.emit("piece:verified", i));
|
|
44
|
+
downloader.on("piece:failed", (i: number, peer: string) => this.emit("piece:failed", i, peer));
|
|
45
|
+
downloader.on("progress", (dl: number, total: number, speed: number) =>
|
|
46
|
+
this.emit("progress", dl, total, speed),
|
|
47
|
+
);
|
|
48
|
+
downloader.on("complete", () => {
|
|
49
|
+
this.transition("seeding");
|
|
50
|
+
this.emit("complete");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
downloader.start();
|
|
54
|
+
return downloader;
|
|
55
|
+
}
|
|
56
|
+
}
|