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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +156 -0
  3. package/bin/torrent-tui +26 -0
  4. package/package.json +58 -0
  5. package/src/app.ts +157 -0
  6. package/src/config/index.ts +41 -0
  7. package/src/config/settings.ts +15 -0
  8. package/src/constants/index.ts +16 -0
  9. package/src/controllers/app-controller.ts +180 -0
  10. package/src/index.ts +320 -0
  11. package/src/layout/add-torrent-dialog.ts +170 -0
  12. package/src/layout/confirm-dialog.ts +141 -0
  13. package/src/layout/content-window.ts +80 -0
  14. package/src/layout/sidebar.ts +121 -0
  15. package/src/layout/status-bar.ts +79 -0
  16. package/src/layout/toast-manager.ts +109 -0
  17. package/src/layout/toast.ts +257 -0
  18. package/src/layout/torrent-view.ts +250 -0
  19. package/src/store/index.ts +51 -0
  20. package/src/theme/default.ts +22 -0
  21. package/src/theme/index.ts +19 -0
  22. package/src/theme/types.ts +26 -0
  23. package/src/torrent/bridge.ts +301 -0
  24. package/src/torrent/downloader.ts +415 -0
  25. package/src/torrent/get_peers.ts +212 -0
  26. package/src/torrent/metadata.ts +190 -0
  27. package/src/torrent/parser.ts +216 -0
  28. package/src/torrent/peer/connection.ts +278 -0
  29. package/src/torrent/peer/handshake.ts +48 -0
  30. package/src/torrent/peer/listener.ts +52 -0
  31. package/src/torrent/peer/manager.ts +233 -0
  32. package/src/torrent/peer/message-buffer.ts +31 -0
  33. package/src/torrent/peer/peer-id.ts +21 -0
  34. package/src/torrent/peer/protocol.ts +123 -0
  35. package/src/torrent/piece-picker.ts +58 -0
  36. package/src/torrent/session.ts +56 -0
  37. package/src/torrent/storage.ts +197 -0
  38. package/src/torrent/tracker/announce.ts +36 -0
  39. package/src/torrent/tracker/http-tracker.ts +143 -0
  40. package/src/torrent/tracker/udp-tracker.ts +136 -0
  41. package/src/torrent/types.ts +25 -0
  42. package/src/types/layout.ts +6 -0
  43. package/src/utils/env.ts +8 -0
  44. package/src/utils/filter.ts +12 -0
  45. package/src/utils/layout.ts +32 -0
  46. package/src/utils/paths.ts +17 -0
@@ -0,0 +1,190 @@
1
+ import { SHA1 } from "bun";
2
+ import { extractInfoBytes } from "./parser.ts";
3
+ import type { BencodeValue } from "./parser.ts";
4
+ import type { FileInfo } from "./types.ts";
5
+
6
+ const TEXT_DECODER = new TextDecoder();
7
+
8
+ export function log(prefix: string, message: string): void {
9
+ const pad = 10;
10
+ console.log(` ${prefix.padEnd(pad)} ${message}`);
11
+ }
12
+
13
+ export class TorrentMetadata {
14
+ readonly name: string;
15
+ readonly totalSize: number;
16
+ readonly pieceLength: number;
17
+ readonly pieceCount: number;
18
+ readonly pieceHashes: Uint8Array[];
19
+ readonly files: FileInfo[];
20
+ readonly infoHash: Uint8Array;
21
+ readonly announceList: string[][];
22
+
23
+ constructor(decoded: { [key: string]: BencodeValue }, rawTorrentBytes: Uint8Array) {
24
+ const info = decoded["info"];
25
+ if (
26
+ typeof info !== "object" ||
27
+ info === null ||
28
+ Array.isArray(info) ||
29
+ info instanceof Uint8Array
30
+ ) {
31
+ throw new Error("Missing or invalid info dict");
32
+ }
33
+ const infoDict = info as { [key: string]: BencodeValue };
34
+
35
+ // name
36
+ const nameRaw = infoDict["name"];
37
+ if (!(nameRaw instanceof Uint8Array) && typeof nameRaw !== "string") {
38
+ throw new Error("Missing name in info dict");
39
+ }
40
+ this.name =
41
+ nameRaw instanceof Uint8Array
42
+ ? TEXT_DECODER.decode(nameRaw)
43
+ : nameRaw;
44
+
45
+ // piece length
46
+ const pl = infoDict["piece length"];
47
+ if (typeof pl !== "number") throw new Error("Missing piece length");
48
+ this.pieceLength = pl;
49
+
50
+ // piece hashes
51
+ const piecesRaw = infoDict["pieces"];
52
+ if (!(piecesRaw instanceof Uint8Array)) {
53
+ throw new Error("Missing or invalid pieces");
54
+ }
55
+ if (piecesRaw.length % 20 !== 0) {
56
+ throw new Error("pieces length is not a multiple of 20");
57
+ }
58
+ this.pieceHashes = [];
59
+ for (let i = 0; i < piecesRaw.length; i += 20) {
60
+ this.pieceHashes.push(piecesRaw.slice(i, i + 20));
61
+ }
62
+ this.pieceCount = this.pieceHashes.length;
63
+
64
+ // file list
65
+ this.files = [];
66
+ let offset = 0;
67
+ const lengthField = infoDict["length"];
68
+ const filesField = infoDict["files"];
69
+
70
+ if (typeof lengthField === "number") {
71
+ // single-file torrent
72
+ this.files.push({ path: this.name, length: lengthField, offset: 0 });
73
+ this.totalSize = lengthField;
74
+ } else if (Array.isArray(filesField)) {
75
+ // multi-file torrent
76
+ for (const entry of filesField) {
77
+ if (
78
+ typeof entry !== "object" ||
79
+ entry === null ||
80
+ Array.isArray(entry) ||
81
+ entry instanceof Uint8Array
82
+ ) {
83
+ throw new Error("Invalid file entry");
84
+ }
85
+ const fileDict = entry as { [key: string]: BencodeValue };
86
+ const fileLen = fileDict["length"];
87
+ const filePath = fileDict["path"];
88
+ if (typeof fileLen !== "number") throw new Error("Invalid file length");
89
+ if (!Array.isArray(filePath)) throw new Error("Invalid file path");
90
+
91
+ const parts = filePath.map((p) => {
92
+ if (p instanceof Uint8Array) return TEXT_DECODER.decode(p);
93
+ if (typeof p === "string") return p;
94
+ throw new Error("Invalid path component");
95
+ });
96
+
97
+ const joinedPath = [this.name, ...parts].join("/");
98
+ this.files.push({ path: joinedPath, length: fileLen, offset });
99
+ offset += fileLen;
100
+ }
101
+ this.totalSize = offset;
102
+ } else {
103
+ throw new Error("info dict must have length or files");
104
+ }
105
+
106
+ // Hash the raw bytes from the original file — not a re-encoded version.
107
+ // BEP 3: must extract the substring directly, not decode-encode roundtrip.
108
+ this.infoHash = SHA1.hash(extractInfoBytes(rawTorrentBytes)) as unknown as Uint8Array;
109
+
110
+ // announce list (BEP 12)
111
+ const announceListRaw = decoded["announce-list"];
112
+ if (Array.isArray(announceListRaw)) {
113
+ this.announceList = announceListRaw
114
+ .filter(Array.isArray)
115
+ .map((tier) =>
116
+ (tier as BencodeValue[])
117
+ .map((url) => {
118
+ if (url instanceof Uint8Array) return TEXT_DECODER.decode(url);
119
+ if (typeof url === "string") return url;
120
+ return null;
121
+ })
122
+ .filter((u): u is string => u !== null),
123
+ )
124
+ .filter((tier) => tier.length > 0);
125
+ } else {
126
+ const announce = decoded["announce"];
127
+ const announceStr =
128
+ announce instanceof Uint8Array
129
+ ? TEXT_DECODER.decode(announce)
130
+ : typeof announce === "string"
131
+ ? announce
132
+ : null;
133
+ this.announceList = announceStr ? [[announceStr]] : [];
134
+ }
135
+ }
136
+
137
+ formatSize(): string {
138
+ const gb = this.totalSize / (1024 * 1024 * 1024);
139
+ if (gb >= 1) return `${gb.toFixed(1)} GB`;
140
+ const mb = this.totalSize / (1024 * 1024);
141
+ if (mb >= 1) return `${mb.toFixed(1)} MB`;
142
+ return `${(this.totalSize / 1024).toFixed(1)} KB`;
143
+ }
144
+
145
+ formatPieceLength(): string {
146
+ const kb = this.pieceLength / 1024;
147
+ if (kb >= 1024) return `${(kb / 1024).toFixed(0)} MB`;
148
+ return `${kb.toFixed(0)} KB`;
149
+ }
150
+
151
+ // Returns which files a piece touches and the byte ranges within each file
152
+ pieceToFileRanges(
153
+ pieceIndex: number,
154
+ ): Array<{ file: FileInfo; fileOffset: number; length: number }> {
155
+ const pieceStart = pieceIndex * this.pieceLength;
156
+ const isLastPiece = pieceIndex === this.pieceCount - 1;
157
+ const pieceLen = isLastPiece
158
+ ? this.totalSize - pieceStart
159
+ : this.pieceLength;
160
+ const pieceEnd = pieceStart + pieceLen;
161
+
162
+ const ranges: Array<{ file: FileInfo; fileOffset: number; length: number }> =
163
+ [];
164
+
165
+ for (const file of this.files) {
166
+ const fileEnd = file.offset + file.length;
167
+ const overlapStart = Math.max(pieceStart, file.offset);
168
+ const overlapEnd = Math.min(pieceEnd, fileEnd);
169
+ if (overlapStart >= overlapEnd) continue;
170
+ ranges.push({
171
+ file,
172
+ fileOffset: overlapStart - file.offset,
173
+ length: overlapEnd - overlapStart,
174
+ });
175
+ }
176
+
177
+ return ranges;
178
+ }
179
+
180
+ logSummary(): void {
181
+ const httpTrackers = this.announceList.flat().filter((u) => u.startsWith("http")).length;
182
+ const udpTrackers = this.announceList.flat().filter((u) => u.startsWith("udp")).length;
183
+ const trackerParts = [
184
+ httpTrackers > 0 ? `${httpTrackers} HTTP` : "",
185
+ udpTrackers > 0 ? `${udpTrackers} UDP` : "",
186
+ ].filter(Boolean).join(" ");
187
+
188
+ log("torrent", `${this.name} ${this.formatSize()} ${this.pieceCount} × ${this.formatPieceLength()} ${trackerParts || "no trackers"}`);
189
+ }
190
+ }
@@ -0,0 +1,216 @@
1
+ export type BencodeValue =
2
+ | string
3
+ | number
4
+ | Uint8Array
5
+ | BencodeValue[]
6
+ | { [key: string]: BencodeValue };
7
+
8
+ const TEXT_DECODER = new TextDecoder(); // bytes -> string
9
+ const TEXT_ENCODER = new TextEncoder(); // string -> bytes
10
+
11
+ /**
12
+ * i<num>e -> number
13
+ */
14
+ export function parseIntB(data: Uint8Array, i: number): [number, number] {
15
+ if (data[i] !== 105) throw new Error("Invalid integer"); // check for "i"
16
+ i++;
17
+ const start = i;
18
+ while (i < data.length && data[i] !== 101) i++; // loop until "e"
19
+ if (i >= data.length) throw new Error("Unterminated integer");
20
+ const value = Number.parseInt(TEXT_DECODER.decode(data.slice(start, i)), 10);
21
+ return [value, i + 1];
22
+ }
23
+
24
+ /**
25
+ * <len>:<bytes> -> Uint8Array (raw binary string)
26
+ */
27
+ export function parseByteString(
28
+ data: Uint8Array,
29
+ i: number,
30
+ ): [Uint8Array, number] {
31
+ let j = i;
32
+ while (j < data.length && data[j] !== 58) j++;
33
+ if (j >= data.length) throw new Error("Invalid string: missing separator");
34
+ const len = Number.parseInt(TEXT_DECODER.decode(data.slice(i, j)), 10);
35
+ if (len < 0) throw new Error("Invalid string: negative length");
36
+ j++;
37
+ if (j + len > data.length)
38
+ throw new Error("Invalid string: length exceeds data");
39
+ return [data.slice(j, j + len), j + len];
40
+ }
41
+
42
+ /**
43
+ * <len>:<str> -> string (UTF-8 text)
44
+ */
45
+ export function parseStringB(data: Uint8Array, i: number): [string, number] {
46
+ const [bytes, newI] = parseByteString(data, i);
47
+ return [TEXT_DECODER.decode(bytes), newI];
48
+ }
49
+
50
+ /**
51
+ * l<items>e -> BencodeValue[]
52
+ */
53
+ export function parseListB(
54
+ data: Uint8Array,
55
+ i: number,
56
+ ): [BencodeValue[], number] {
57
+ if (data[i] !== 108) throw new Error("Invalid list"); // check for "l"
58
+ i++;
59
+ const arr: BencodeValue[] = [];
60
+ while (i < data.length && data[i] !== 101) {
61
+ const [val, newI] = parseAny(data, i);
62
+ arr.push(val);
63
+ i = newI;
64
+ }
65
+ if (i >= data.length) throw new Error("Unterminated list");
66
+ return [arr, i + 1];
67
+ }
68
+
69
+ /**
70
+ * d<key-value>e -> { [key: string]: BencodeValue }
71
+ */
72
+ export function parseDictB(
73
+ data: Uint8Array,
74
+ i: number,
75
+ ): [{ [key: string]: BencodeValue }, number] {
76
+ if (data[i] !== 100) throw new Error("Invalid dictionary"); // check for "d"
77
+ i++;
78
+ const dict: { [key: string]: BencodeValue } = {};
79
+ while (i < data.length && data[i] !== 101) {
80
+ const [key, newI] = parseStringB(data, i);
81
+ const keyStr = key;
82
+ const [val, nextI] = parseAny(data, newI);
83
+ dict[keyStr] = val;
84
+ i = nextI;
85
+ }
86
+ if (i >= data.length) throw new Error("Unterminated dictionary");
87
+ return [dict, i + 1];
88
+ }
89
+
90
+ /**
91
+ * Parse any bencode value
92
+ */
93
+ export function parseAny(data: Uint8Array, i: number): [BencodeValue, number] {
94
+ const byte = data[i];
95
+ if (byte === undefined) throw new Error(`Invalid bencode type at index ${i}`);
96
+ if (byte === 105) return parseIntB(data, i);
97
+ if (byte === 108) return parseListB(data, i);
98
+ if (byte === 100) return parseDictB(data, i);
99
+ if (byte >= 48 && byte <= 57) return parseByteString(data, i);
100
+ throw new Error(`Invalid bencode type at index ${i}: ${byte}`);
101
+ }
102
+
103
+ /**
104
+ * Decodes bencode data to BencodeValue
105
+ */
106
+ export function decode(data: Uint8Array): BencodeValue {
107
+ const [result, index] = parseAny(data, 0);
108
+ if (index < data.length) throw new Error(`Extra data at index ${index}`);
109
+ return result;
110
+ }
111
+
112
+ /**
113
+ * Skips over a single bencode value and returns the index after it.
114
+ * Used to extract raw byte ranges without decoding.
115
+ */
116
+ function skipValue(data: Uint8Array, i: number): number {
117
+ const byte = data[i];
118
+ if (byte === undefined) throw new Error(`Unexpected end at ${i}`);
119
+ if (byte === 105) { // integer: i<digits>e
120
+ while (i < data.length && data[i] !== 101) i++;
121
+ return i + 1;
122
+ }
123
+ if (byte === 108) { // list: l<items>e
124
+ i++;
125
+ while (i < data.length && data[i] !== 101) i = skipValue(data, i);
126
+ return i + 1;
127
+ }
128
+ if (byte === 100) { // dict: d<key><value>...e
129
+ i++;
130
+ while (i < data.length && data[i] !== 101) {
131
+ i = skipValue(data, i); // key
132
+ i = skipValue(data, i); // value
133
+ }
134
+ return i + 1;
135
+ }
136
+ if (byte >= 48 && byte <= 57) { // string: <len>:<bytes>
137
+ let j = i;
138
+ while (j < data.length && data[j] !== 58) j++;
139
+ const len = Number.parseInt(TEXT_DECODER.decode(data.slice(i, j)), 10);
140
+ return j + 1 + len;
141
+ }
142
+ throw new Error(`Unknown bencode type at ${i}: ${byte}`);
143
+ }
144
+
145
+ /**
146
+ * Extracts the raw bytes of the 'info' dict from a torrent file.
147
+ * This is what must be SHA-1 hashed for the info_hash — NOT a re-encoded version.
148
+ * BEP 3: "clients must extract the substring directly, not perform a decode-encode roundtrip."
149
+ */
150
+ export function extractInfoBytes(data: Uint8Array): Uint8Array {
151
+ if (data[0] !== 100) throw new Error("Torrent file is not a bencode dict");
152
+ let i = 1;
153
+ while (i < data.length && data[i] !== 101) {
154
+ const [key, afterKey] = parseStringB(data, i);
155
+ const valueStart = afterKey;
156
+ if (key === "info") {
157
+ const valueEnd = skipValue(data, valueStart);
158
+ return data.slice(valueStart, valueEnd);
159
+ }
160
+ i = skipValue(data, valueStart);
161
+ }
162
+ throw new Error("No 'info' key found in torrent file");
163
+ }
164
+
165
+ /**
166
+ * Encodes BencodeValue to bencode bytes
167
+ */
168
+ export function encode(value: BencodeValue): Uint8Array {
169
+ if (typeof value === "number") {
170
+ if (!Number.isFinite(value)) {
171
+ throw new Error(
172
+ "Cannot encode non-finite numbers (NaN, Infinity, -Infinity)",
173
+ );
174
+ }
175
+ return TEXT_ENCODER.encode(`i${value}e`);
176
+ }
177
+ if (typeof value === "string") {
178
+ const bytes = TEXT_ENCODER.encode(value);
179
+ const len = TEXT_ENCODER.encode(String(bytes.length));
180
+ return Uint8Array.from([...len, 58, ...bytes]);
181
+ }
182
+ if (value instanceof Uint8Array) {
183
+ const len = TEXT_ENCODER.encode(String(value.length));
184
+ return Uint8Array.from([...len, 58, ...value]);
185
+ }
186
+ if (Array.isArray(value)) {
187
+ const parts: Uint8Array[] = [TEXT_ENCODER.encode("l")];
188
+ for (const item of value) parts.push(encode(item));
189
+ parts.push(TEXT_ENCODER.encode("e"));
190
+ return concat(parts);
191
+ }
192
+ if (typeof value === "object" && value !== null) {
193
+ const parts: Uint8Array[] = [TEXT_ENCODER.encode("d")];
194
+ const keys = Object.keys(value).sort();
195
+ for (const key of keys) {
196
+ const val = value[key];
197
+ if (val === undefined) continue;
198
+ parts.push(encode(key));
199
+ parts.push(encode(val));
200
+ }
201
+ parts.push(TEXT_ENCODER.encode("e"));
202
+ return concat(parts);
203
+ }
204
+ throw new Error(`Unsupported type: ${typeof value}`);
205
+ }
206
+
207
+ function concat(arrays: Uint8Array[]): Uint8Array {
208
+ const total = arrays.reduce((sum, a) => sum + a.length, 0);
209
+ const result = new Uint8Array(total);
210
+ let offset = 0;
211
+ for (const arr of arrays) {
212
+ result.set(arr, offset);
213
+ offset += arr.length;
214
+ }
215
+ return result;
216
+ }
@@ -0,0 +1,278 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { createConnection } from "node:net";
3
+ import type { Socket } from "node:net";
4
+ import { log } from "../metadata.ts";
5
+ import { MessageBuffer } from "./message-buffer.ts";
6
+ import {
7
+ MSG,
8
+ decode as decodeMsg,
9
+ encode as encodeMsg,
10
+ encodeHave,
11
+ encodeRequest,
12
+ encodeCancel,
13
+ decodeHave,
14
+ decodePiece,
15
+ decodeRequest,
16
+ } from "./protocol.ts";
17
+ import { buildHandshake, parseHandshake, HANDSHAKE_LEN } from "./handshake.ts";
18
+ import { getPeerId } from "./peer-id.ts";
19
+
20
+ const KEEPALIVE_INTERVAL_MS = 120_000;
21
+ const CONNECT_TIMEOUT_MS = 10_000;
22
+ const IDLE_TIMEOUT_MS = 120_000;
23
+
24
+ export class PeerConnection extends EventEmitter {
25
+ readonly address: string;
26
+ readonly port: number;
27
+ peerId: string = "";
28
+
29
+ amChoked = true;
30
+ amInterested = false;
31
+ peerChoked = true;
32
+ peerInterested = false;
33
+ piecesBitfield: Uint8Array = new Uint8Array(0);
34
+ suppressDisconnect = false;
35
+
36
+ // Per-peer rate tracking — reset every 10s by PeerManager
37
+ downloadedThisInterval = 0;
38
+ uploadedThisInterval = 0;
39
+ downloadBytesPerSec = 0;
40
+ uploadBytesPerSec = 0;
41
+
42
+ private socket: Socket | null = null;
43
+ private buf = new MessageBuffer();
44
+ private handshakeDone = false;
45
+ private handshakeBuffer = new Uint8Array(0);
46
+ private keepaliveTimer: ReturnType<typeof setInterval> | null = null;
47
+ private idleTimer: ReturnType<typeof setTimeout> | null = null;
48
+ private infoHash: Uint8Array;
49
+ private settle?: (err?: Error) => void;
50
+
51
+ constructor(
52
+ address: string,
53
+ port: number,
54
+ infoHash: Uint8Array,
55
+ ) {
56
+ super();
57
+ this.address = address;
58
+ this.port = port;
59
+ this.infoHash = infoHash;
60
+ }
61
+
62
+ connect(): Promise<void> {
63
+ return new Promise((resolve, reject) => {
64
+ let settled = false;
65
+ this.settle = (err?: Error) => {
66
+ if (settled) return;
67
+ settled = true;
68
+ clearTimeout(timeout); // clear here so 10s guard stays active until handshake
69
+ this.settle = undefined;
70
+ if (err) reject(err);
71
+ else resolve();
72
+ };
73
+
74
+ const sock = createConnection({ host: this.address, port: this.port });
75
+ this.socket = sock;
76
+
77
+ // 10s covers both TCP connect AND handshake completion.
78
+ // Do NOT clear on TCP connect — only clear when promise settles.
79
+ const timeout = setTimeout(() => {
80
+ log("timeout", `${this.address}:${this.port} after ${CONNECT_TIMEOUT_MS / 1000}s`);
81
+ sock.destroy();
82
+ this.settle?.(new Error("connect timeout"));
83
+ }, CONNECT_TIMEOUT_MS);
84
+
85
+ sock.once("connect", () => {
86
+ sock.write(buildHandshake(this.infoHash, getPeerId()));
87
+ this.resetIdleTimer();
88
+ // Timeout continues running until handshake completes or times out
89
+ });
90
+
91
+ sock.on("data", (chunk: Buffer) => {
92
+ this.resetIdleTimer();
93
+ this.onData(new Uint8Array(chunk));
94
+ });
95
+
96
+ sock.once("error", (err) => {
97
+ log("error", `${this.address}:${this.port} ${err.message}`);
98
+ this.settle?.(err); // settle() clears timeout
99
+ });
100
+
101
+ sock.once("close", () => {
102
+ this.cleanup();
103
+ this.settle?.(new Error("closed before handshake"));
104
+ this.emit("disconnect");
105
+ });
106
+ });
107
+ }
108
+
109
+ private onData(chunk: Uint8Array): void {
110
+ if (!this.handshakeDone) {
111
+ // Accumulate until we have 68 bytes
112
+ const merged = new Uint8Array(this.handshakeBuffer.length + chunk.length);
113
+ merged.set(this.handshakeBuffer);
114
+ merged.set(chunk, this.handshakeBuffer.length);
115
+ this.handshakeBuffer = merged;
116
+
117
+ if (this.handshakeBuffer.length < HANDSHAKE_LEN) return;
118
+
119
+ try {
120
+ const result = parseHandshake(this.handshakeBuffer, this.infoHash);
121
+ this.peerId = result.peerId;
122
+ this.handshakeDone = true;
123
+ this.startKeepalive();
124
+ log("handshake", `${this.address}:${this.port} ${this.peerId.slice(0, 8)}`);
125
+ this.settle?.(); // resolve the connect() promise
126
+ const remainder = this.handshakeBuffer.slice(HANDSHAKE_LEN);
127
+ this.handshakeBuffer = new Uint8Array(0);
128
+ if (remainder.length > 0) this.onMessages(this.buf.push(remainder));
129
+ } catch (err) {
130
+ const msg = err instanceof Error ? err.message : String(err);
131
+ log("handshake", `${this.address}:${this.port} fail ${msg}`);
132
+ this.settle?.(new Error(msg));
133
+ this.socket?.destroy();
134
+ }
135
+ return;
136
+ }
137
+
138
+ this.onMessages(this.buf.push(chunk));
139
+ }
140
+
141
+ private onMessages(messages: Uint8Array[]): void {
142
+ for (const raw of messages) {
143
+ const msg = decodeMsg(raw);
144
+ switch (msg.type) {
145
+ case MSG.CHOKE:
146
+ this.amChoked = true;
147
+ this.emit("choke");
148
+ break;
149
+ case MSG.UNCHOKE:
150
+ this.amChoked = false;
151
+ this.emit("unchoke");
152
+ break;
153
+ case MSG.INTERESTED:
154
+ this.peerInterested = true;
155
+ break;
156
+ case MSG.NOT_INTERESTED:
157
+ this.peerInterested = false;
158
+ break;
159
+ case MSG.HAVE:
160
+ if (msg.payload) {
161
+ const idx = decodeHave(msg.payload);
162
+ this.emit("have", idx);
163
+ }
164
+ break;
165
+ case MSG.BITFIELD:
166
+ if (msg.payload) {
167
+ this.piecesBitfield = msg.payload;
168
+ this.emit("bitfield", msg.payload);
169
+ }
170
+ break;
171
+ case MSG.PIECE:
172
+ if (msg.payload) {
173
+ const { index, begin, block } = decodePiece(msg.payload);
174
+ this.downloadedThisInterval += block.length;
175
+ this.emit("piece", index, begin, block);
176
+ }
177
+ break;
178
+ case MSG.REQUEST:
179
+ if (msg.payload) {
180
+ const req = decodeRequest(msg.payload);
181
+ this.emit("request", req.index, req.begin, req.length);
182
+ }
183
+ break;
184
+ case MSG.KEEPALIVE:
185
+ break;
186
+ }
187
+ }
188
+ }
189
+
190
+ countPiecesPublic(): number {
191
+ let n = 0;
192
+ for (const byte of this.piecesBitfield) {
193
+ let b = byte;
194
+ while (b) { n += b & 1; b >>>= 1; }
195
+ }
196
+ return n;
197
+ }
198
+
199
+ hasPiece(index: number): boolean {
200
+ const byte = Math.floor(index / 8);
201
+ const bit = 7 - (index % 8);
202
+ return ((this.piecesBitfield[byte] ?? 0) & (1 << bit)) !== 0;
203
+ }
204
+
205
+ sendInterested(): void {
206
+ this.amInterested = true;
207
+ this.write(encodeMsg({ type: MSG.INTERESTED }));
208
+ }
209
+
210
+ sendNotInterested(): void {
211
+ this.amInterested = false;
212
+ this.write(encodeMsg({ type: MSG.NOT_INTERESTED }));
213
+ }
214
+
215
+ sendRequest(index: number, begin: number, length: number): void {
216
+ this.write(encodeRequest(index, begin, length));
217
+ }
218
+
219
+ sendCancel(index: number, begin: number, length: number): void {
220
+ this.write(encodeCancel(index, begin, length));
221
+ }
222
+
223
+ sendHave(index: number): void {
224
+ this.write(encodeHave(index));
225
+ }
226
+
227
+ sendBitfield(bitfield: Uint8Array): void {
228
+ this.write(encodeMsg({ type: MSG.BITFIELD, payload: bitfield }));
229
+ }
230
+
231
+ sendChoke(): void {
232
+ this.peerChoked = true;
233
+ this.write(encodeMsg({ type: MSG.CHOKE }));
234
+ }
235
+
236
+ sendUnchoke(): void {
237
+ this.peerChoked = false;
238
+ this.write(encodeMsg({ type: MSG.UNCHOKE }));
239
+ }
240
+
241
+ sendPiece(index: number, begin: number, block: Uint8Array): void {
242
+ const payload = new Uint8Array(8 + block.length);
243
+ const view = new DataView(payload.buffer);
244
+ view.setUint32(0, index);
245
+ view.setUint32(4, begin);
246
+ payload.set(block, 8);
247
+ this.write(encodeMsg({ type: MSG.PIECE, payload }));
248
+ this.uploadedThisInterval += block.length;
249
+ }
250
+
251
+ private startKeepalive(): void {
252
+ this.keepaliveTimer = setInterval(() => {
253
+ this.write(encodeMsg({ type: MSG.KEEPALIVE }));
254
+ }, KEEPALIVE_INTERVAL_MS);
255
+ }
256
+
257
+ private resetIdleTimer(): void {
258
+ if (this.idleTimer) clearTimeout(this.idleTimer);
259
+ this.idleTimer = setTimeout(() => {
260
+ log("timeout", `${this.address}:${this.port} idle`);
261
+ this.socket?.destroy();
262
+ }, IDLE_TIMEOUT_MS);
263
+ }
264
+
265
+ private write(data: Uint8Array): void {
266
+ this.socket?.write(data);
267
+ }
268
+
269
+ private cleanup(): void {
270
+ if (this.keepaliveTimer) clearInterval(this.keepaliveTimer);
271
+ if (this.idleTimer) clearTimeout(this.idleTimer);
272
+ }
273
+
274
+ destroy(): void {
275
+ this.cleanup();
276
+ this.socket?.destroy();
277
+ }
278
+ }