bun-torrent 0.0.1-beta
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 +1 -0
- package/package.json +49 -0
- package/src/app.ts +65 -0
- package/src/client.error.ts +12 -0
- package/src/client.test.ts +117 -0
- package/src/client.ts +185 -0
- package/src/index.test.ts +7 -0
- package/src/index.ts +76 -0
- package/src/peer/__tests__/peer-id.test.ts +24 -0
- package/src/peer/availability.test.ts +50 -0
- package/src/peer/availability.ts +88 -0
- package/src/peer/consts.ts +11 -0
- package/src/peer/handshake/handshake.error.ts +15 -0
- package/src/peer/handshake/handshake.test.ts +125 -0
- package/src/peer/handshake/index.ts +83 -0
- package/src/peer/index.ts +31 -0
- package/src/peer/messages/error.ts +16 -0
- package/src/peer/messages/helpers.ts +36 -0
- package/src/peer/messages/index.ts +174 -0
- package/src/peer/messages/messages.test.ts +231 -0
- package/src/peer/messages/types.ts +76 -0
- package/src/peer/peer-id.ts +9 -0
- package/src/peer/pool/index.ts +301 -0
- package/src/peer/pool/pool.error.ts +17 -0
- package/src/peer/pool/pool.test.ts +305 -0
- package/src/peer/session/index.ts +276 -0
- package/src/peer/session/session.error.ts +19 -0
- package/src/peer/session/session.test.ts +110 -0
- package/src/peer/types.ts +6 -0
- package/src/torrent/bencode/__tests__/decoder.test.ts +212 -0
- package/src/torrent/bencode/__tests__/encoder.test.ts +138 -0
- package/src/torrent/bencode/__tests__/encoder.unit.test.ts +24 -0
- package/src/torrent/bencode/__tests__/integration.test.ts +64 -0
- package/src/torrent/bencode/decoder.error.ts +36 -0
- package/src/torrent/bencode/decoder.ts +180 -0
- package/src/torrent/bencode/encoder.error.ts +14 -0
- package/src/torrent/bencode/encoder.ts +86 -0
- package/src/torrent/bencode/index.ts +9 -0
- package/src/torrent/bencode/types.ts +15 -0
- package/src/torrent/bencode/utils.ts +17 -0
- package/src/torrent/download/index.ts +9 -0
- package/src/torrent/download/manager.test.ts +393 -0
- package/src/torrent/download/manager.ts +376 -0
- package/src/torrent/file-selection.ts +51 -0
- package/src/torrent/index.ts +61 -0
- package/src/torrent/parser/helpers.ts +44 -0
- package/src/torrent/parser/index.ts +197 -0
- package/src/torrent/parser/info-hash.test.ts +39 -0
- package/src/torrent/parser/info-hash.ts +7 -0
- package/src/torrent/parser/parser.error.ts +21 -0
- package/src/torrent/parser/parser.test.ts +286 -0
- package/src/torrent/pieces/DefaultPlanner.ts +257 -0
- package/src/torrent/pieces/index.ts +20 -0
- package/src/torrent/pieces/planner.error.ts +18 -0
- package/src/torrent/pieces/planner.test.ts +303 -0
- package/src/torrent/pieces/planner.ts +16 -0
- package/src/torrent/pieces/types.ts +59 -0
- package/src/torrent/pieces/utils.ts +85 -0
- package/src/torrent/pieces/validation.test.ts +32 -0
- package/src/torrent/pieces/validation.ts +27 -0
- package/src/torrent/session/index.ts +195 -0
- package/src/torrent/session/session.test.ts +279 -0
- package/src/torrent/storage/index.ts +161 -0
- package/src/torrent/storage/storage.error.ts +18 -0
- package/src/torrent/storage/storage.test.ts +326 -0
- package/src/torrent/storage/types.ts +13 -0
- package/src/torrent/types.ts +16 -0
- package/src/tracker/http.test.ts +66 -0
- package/src/tracker/http.ts +137 -0
- package/src/tracker/index.ts +93 -0
- package/src/tracker/tracker.error.ts +26 -0
- package/src/tracker/types.ts +17 -0
- package/src/tracker/udp.test.ts +155 -0
- package/src/tracker/udp.ts +234 -0
- package/src/utils/__tests__/sha1.test.ts +16 -0
- package/src/utils/buffers.ts +28 -0
- package/src/utils/errors.ts +9 -0
- package/src/utils/formats.ts +8 -0
- package/src/utils/sha1.ts +4 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { TorrentMetadata } from '@torrent/types';
|
|
2
|
+
import type { PieceBlock, PieceBlockRequest } from './types';
|
|
3
|
+
import { PiecePlannerError, PiecePlannerErrorCode } from './planner.error';
|
|
4
|
+
|
|
5
|
+
export const DEFAULT_BLOCK_LENGTH = 16 * 1024;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Return the byte length of a piece, including the usually shorter final piece.
|
|
9
|
+
*
|
|
10
|
+
* @param metadata - Parsed torrent metadata.
|
|
11
|
+
* @param pieceIndex - Zero-based piece index.
|
|
12
|
+
* @returns The byte length for the requested piece.
|
|
13
|
+
* @throws {PiecePlannerError} When `pieceIndex` is outside the torrent piece range.
|
|
14
|
+
*/
|
|
15
|
+
export const getPieceLength = (metadata: TorrentMetadata, pieceIndex: number): number => {
|
|
16
|
+
if (!Number.isInteger(pieceIndex) || pieceIndex < 0 || pieceIndex >= metadata.pieces.length) {
|
|
17
|
+
throw new PiecePlannerError(
|
|
18
|
+
PiecePlannerErrorCode.INVALID_PIECE_INDEX,
|
|
19
|
+
`Unknown piece index: ${pieceIndex}`,
|
|
20
|
+
pieceIndex,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (pieceIndex === metadata.pieces.length - 1)
|
|
25
|
+
return metadata.length - pieceIndex * metadata.pieceLength;
|
|
26
|
+
return metadata.pieceLength;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Split one piece into protocol request messages with a fixed maximum block size.
|
|
31
|
+
*
|
|
32
|
+
* @param pieceIndex - Zero-based piece index.
|
|
33
|
+
* @param pieceLength - Total byte length of the piece being split.
|
|
34
|
+
* @param blockLength - Maximum bytes per request. Defaults to `DEFAULT_BLOCK_LENGTH`.
|
|
35
|
+
* @returns Request messages that cover the piece in order.
|
|
36
|
+
* @throws {PiecePlannerError} When `pieceLength` or `blockLength` is not a positive integer.
|
|
37
|
+
*/
|
|
38
|
+
export const splitPieceIntoRequests = ({
|
|
39
|
+
pieceIndex,
|
|
40
|
+
pieceLength,
|
|
41
|
+
blockLength = DEFAULT_BLOCK_LENGTH,
|
|
42
|
+
}: {
|
|
43
|
+
pieceIndex: number;
|
|
44
|
+
pieceLength: number;
|
|
45
|
+
blockLength?: number;
|
|
46
|
+
}): PieceBlockRequest[] => {
|
|
47
|
+
if (!Number.isInteger(pieceLength) || pieceLength <= 0) {
|
|
48
|
+
throw new PiecePlannerError(
|
|
49
|
+
PiecePlannerErrorCode.INVALID_PIECE_LENGTH,
|
|
50
|
+
`Piece length must be a positive integer: ${pieceLength}`,
|
|
51
|
+
pieceIndex,
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!Number.isInteger(blockLength) || blockLength <= 0) {
|
|
56
|
+
throw new PiecePlannerError(
|
|
57
|
+
PiecePlannerErrorCode.INVALID_BLOCK_LENGTH,
|
|
58
|
+
`Block length must be a positive integer: ${blockLength}`,
|
|
59
|
+
pieceIndex,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const n = Math.ceil(pieceLength / blockLength);
|
|
64
|
+
const blocks = new Array<PieceBlockRequest>(n);
|
|
65
|
+
for (let i = 0; i < n; i++)
|
|
66
|
+
blocks[i] = {
|
|
67
|
+
type: 'request',
|
|
68
|
+
pieceIndex,
|
|
69
|
+
offset: blockLength * i,
|
|
70
|
+
length: i === n - 1 ? pieceLength - blockLength * i : blockLength,
|
|
71
|
+
};
|
|
72
|
+
return blocks;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Check whether a received block exactly satisfies a pending request.
|
|
77
|
+
*
|
|
78
|
+
* @param block - Received peer piece message.
|
|
79
|
+
* @param request - Request the block is expected to satisfy.
|
|
80
|
+
* @returns True when piece index, offset, and byte length all match.
|
|
81
|
+
*/
|
|
82
|
+
export const isValidBlockForRequest = (block: PieceBlock, request: PieceBlockRequest): boolean =>
|
|
83
|
+
block.pieceIndex === request.pieceIndex &&
|
|
84
|
+
block.offset === request.offset &&
|
|
85
|
+
block.block.byteLength === request.length;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { sha1 } from '@utils/sha1';
|
|
4
|
+
import { validatePiece } from './validation';
|
|
5
|
+
|
|
6
|
+
describe('validatePiece', () => {
|
|
7
|
+
test('accepts a piece when its SHA-1 hash matches', () => {
|
|
8
|
+
const data = new Uint8Array([1, 2, 3, 4]);
|
|
9
|
+
const result = validatePiece({
|
|
10
|
+
pieceIndex: 2,
|
|
11
|
+
data,
|
|
12
|
+
expectedHash: sha1(data),
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
expect(result).toMatchObject({
|
|
16
|
+
pieceIndex: 2,
|
|
17
|
+
valid: true,
|
|
18
|
+
});
|
|
19
|
+
expect(result.actualHash).toEqual(result.expectedHash);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test('rejects a piece when its SHA-1 hash does not match', () => {
|
|
23
|
+
const result = validatePiece({
|
|
24
|
+
pieceIndex: 1,
|
|
25
|
+
data: new Uint8Array([1, 2, 3, 4]),
|
|
26
|
+
expectedHash: sha1(new Uint8Array([4, 3, 2, 1])),
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
expect(result.valid).toBe(false);
|
|
30
|
+
expect(result.actualHash).not.toEqual(result.expectedHash);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { compareBytes } from '@utils/buffers';
|
|
2
|
+
import { sha1 } from '@utils/sha1';
|
|
3
|
+
import type { PieceCompletion } from './types';
|
|
4
|
+
|
|
5
|
+
export type PieceValidationResult = {
|
|
6
|
+
pieceIndex: number;
|
|
7
|
+
actualHash: Uint8Array;
|
|
8
|
+
expectedHash: Uint8Array;
|
|
9
|
+
valid: boolean;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Validate an assembled piece against the expected torrent metadata hash.
|
|
14
|
+
*
|
|
15
|
+
* @param piece - Completed piece returned by the planner.
|
|
16
|
+
* @returns Validation result containing both actual and expected SHA-1 hashes.
|
|
17
|
+
*/
|
|
18
|
+
export const validatePiece = (piece: PieceCompletion): PieceValidationResult => {
|
|
19
|
+
const actualHash = sha1(piece.data);
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
pieceIndex: piece.pieceIndex,
|
|
23
|
+
actualHash,
|
|
24
|
+
expectedHash: piece.expectedHash,
|
|
25
|
+
valid: compareBytes(actualHash, piece.expectedHash) === 0,
|
|
26
|
+
};
|
|
27
|
+
};
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import type { DownloadProgress, DownloadProgressListener } from '../download';
|
|
2
|
+
import type { TorrentFile, TorrentMetadata } from '../types';
|
|
3
|
+
|
|
4
|
+
export type TorrentStats = {
|
|
5
|
+
peers: number;
|
|
6
|
+
connections: number;
|
|
7
|
+
connecting: number;
|
|
8
|
+
connectionAttempts: number;
|
|
9
|
+
failedConnections: number;
|
|
10
|
+
targetConnections: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export enum TorrentState {
|
|
14
|
+
DOWNLOADING = 'downloading',
|
|
15
|
+
COMPLETED = 'completed',
|
|
16
|
+
FAILED = 'failed',
|
|
17
|
+
CLOSED = 'closed',
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type TorrentStateChange = {
|
|
21
|
+
previous: TorrentState;
|
|
22
|
+
state: TorrentState;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type TorrentEventMap = {
|
|
26
|
+
close: [];
|
|
27
|
+
done: [];
|
|
28
|
+
error: [error: unknown];
|
|
29
|
+
peer: [session: unknown];
|
|
30
|
+
progress: [progress: DownloadProgress];
|
|
31
|
+
state: [change: TorrentStateChange];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type TorrentEventName = keyof TorrentEventMap;
|
|
35
|
+
|
|
36
|
+
export type TorrentEventListener<TEvent extends TorrentEventName> = (
|
|
37
|
+
...args: TorrentEventMap[TEvent]
|
|
38
|
+
) => void;
|
|
39
|
+
|
|
40
|
+
export type TorrentFiles = {
|
|
41
|
+
included: TorrentFile[];
|
|
42
|
+
excluded: TorrentFile[];
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
type TorrentPeerPool = {
|
|
46
|
+
readonly done: Promise<unknown>;
|
|
47
|
+
readonly totalPeers: number;
|
|
48
|
+
readonly size: number;
|
|
49
|
+
readonly connecting: number;
|
|
50
|
+
readonly attempted: number;
|
|
51
|
+
readonly failed: number;
|
|
52
|
+
readonly targetConnections: number;
|
|
53
|
+
onSession(callback: (session: unknown) => void): () => void;
|
|
54
|
+
close(): void;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
type TorrentDownloadManager = {
|
|
58
|
+
readonly done: Promise<void>;
|
|
59
|
+
readonly progress: DownloadProgress;
|
|
60
|
+
onProgress(listener: DownloadProgressListener): () => void;
|
|
61
|
+
start(): void;
|
|
62
|
+
close(): void;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export class Torrent {
|
|
66
|
+
public readonly done: Promise<void>;
|
|
67
|
+
private readonly listeners = new Map<TorrentEventName, Set<(...args: unknown[]) => void>>();
|
|
68
|
+
private currentState = TorrentState.DOWNLOADING;
|
|
69
|
+
private lastError: unknown;
|
|
70
|
+
|
|
71
|
+
public constructor(
|
|
72
|
+
public readonly metadata: TorrentMetadata,
|
|
73
|
+
private readonly peerPool: TorrentPeerPool,
|
|
74
|
+
private readonly downloadManager?: TorrentDownloadManager,
|
|
75
|
+
private readonly selectedFiles?: Set<string> | null,
|
|
76
|
+
) {
|
|
77
|
+
this.downloadManager?.onProgress((progress) => this.emit('progress', progress));
|
|
78
|
+
this.downloadManager?.start();
|
|
79
|
+
this.done = downloadManager?.done ?? peerPool.done.then(() => undefined);
|
|
80
|
+
void this.done.then(
|
|
81
|
+
() => {
|
|
82
|
+
if (this.currentState === TorrentState.CLOSED) return;
|
|
83
|
+
|
|
84
|
+
this.setState(TorrentState.COMPLETED);
|
|
85
|
+
this.emit('done');
|
|
86
|
+
},
|
|
87
|
+
(error) => {
|
|
88
|
+
if (this.currentState === TorrentState.CLOSED) return;
|
|
89
|
+
|
|
90
|
+
this.lastError = error;
|
|
91
|
+
this.setState(TorrentState.FAILED);
|
|
92
|
+
this.emit('error', error);
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
public get state(): TorrentState {
|
|
98
|
+
return this.currentState;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
public get error(): unknown {
|
|
102
|
+
return this.lastError;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
public get stats(): TorrentStats {
|
|
106
|
+
return {
|
|
107
|
+
peers: this.peerPool.totalPeers,
|
|
108
|
+
connections: this.peerPool.size,
|
|
109
|
+
connecting: this.peerPool.connecting,
|
|
110
|
+
connectionAttempts: this.peerPool.attempted,
|
|
111
|
+
failedConnections: this.peerPool.failed,
|
|
112
|
+
targetConnections: this.peerPool.targetConnections,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
public get progress(): DownloadProgress {
|
|
117
|
+
return (
|
|
118
|
+
this.downloadManager?.progress ?? {
|
|
119
|
+
totalBytes: this.metadata.length,
|
|
120
|
+
receivedBytes: 0,
|
|
121
|
+
downloadedBytes: 0,
|
|
122
|
+
totalPieces: this.metadata.pieces.length,
|
|
123
|
+
completedPieces: 0,
|
|
124
|
+
percent: this.metadata.length === 0 ? 1 : 0,
|
|
125
|
+
speedBytesPerSecond: 0,
|
|
126
|
+
speed: '0.0 Bps',
|
|
127
|
+
}
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
public get files(): TorrentFiles {
|
|
132
|
+
if (!this.selectedFiles) return { included: this.metadata.files, excluded: [] };
|
|
133
|
+
|
|
134
|
+
const included = this.metadata.files.filter((f) =>
|
|
135
|
+
this.selectedFiles?.has(f.path.join('/')),
|
|
136
|
+
);
|
|
137
|
+
const excluded = this.metadata.files.filter(
|
|
138
|
+
(f) => !this.selectedFiles?.has(f.path.join('/')),
|
|
139
|
+
);
|
|
140
|
+
return { included, excluded };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
public on<TEvent extends TorrentEventName>(
|
|
144
|
+
event: TEvent,
|
|
145
|
+
listener: TorrentEventListener<TEvent>,
|
|
146
|
+
): () => void {
|
|
147
|
+
if (event === 'peer') {
|
|
148
|
+
return this.peerPool.onSession((session) => {
|
|
149
|
+
(listener as TorrentEventListener<'peer'>)(session);
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const listeners = this.getListeners(event);
|
|
154
|
+
listeners.add(listener as (...args: unknown[]) => void);
|
|
155
|
+
|
|
156
|
+
return () => {
|
|
157
|
+
listeners.delete(listener as (...args: unknown[]) => void);
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
public close(): void {
|
|
162
|
+
if (this.currentState === TorrentState.CLOSED) return;
|
|
163
|
+
|
|
164
|
+
this.downloadManager?.close();
|
|
165
|
+
this.peerPool.close();
|
|
166
|
+
this.setState(TorrentState.CLOSED);
|
|
167
|
+
this.emit('close');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private setState(state: TorrentState): void {
|
|
171
|
+
if (this.currentState === state) return;
|
|
172
|
+
|
|
173
|
+
const previous = this.currentState;
|
|
174
|
+
this.currentState = state;
|
|
175
|
+
this.emit('state', { previous, state });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private emit<TEvent extends TorrentEventName>(
|
|
179
|
+
event: TEvent,
|
|
180
|
+
...args: TorrentEventMap[TEvent]
|
|
181
|
+
): void {
|
|
182
|
+
for (const listener of this.getListeners(event)) {
|
|
183
|
+
listener(...args);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private getListeners(event: TorrentEventName): Set<(...args: unknown[]) => void> {
|
|
188
|
+
const existing = this.listeners.get(event);
|
|
189
|
+
if (existing) return existing;
|
|
190
|
+
|
|
191
|
+
const listeners = new Set<(...args: unknown[]) => void>();
|
|
192
|
+
this.listeners.set(event, listeners);
|
|
193
|
+
return listeners;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { Torrent, TorrentState } from '.';
|
|
4
|
+
import type { DownloadProgress, DownloadProgressListener } from '../download';
|
|
5
|
+
import type { TorrentMetadata } from '../types';
|
|
6
|
+
|
|
7
|
+
describe('Torrent', () => {
|
|
8
|
+
test('returns live stats from the peer pool', () => {
|
|
9
|
+
const pool = new FakePeerPool();
|
|
10
|
+
const torrent = new Torrent(makeMetadata(), pool);
|
|
11
|
+
|
|
12
|
+
expect(torrent.stats).toEqual({
|
|
13
|
+
peers: 10,
|
|
14
|
+
connections: 1,
|
|
15
|
+
connecting: 2,
|
|
16
|
+
connectionAttempts: 3,
|
|
17
|
+
failedConnections: 4,
|
|
18
|
+
targetConnections: 5,
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
pool.size = 2;
|
|
22
|
+
pool.connecting = 1;
|
|
23
|
+
|
|
24
|
+
expect(torrent.stats.connections).toBe(2);
|
|
25
|
+
expect(torrent.stats.connecting).toBe(1);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test('closes the peer pool', () => {
|
|
29
|
+
const pool = new FakePeerPool();
|
|
30
|
+
const torrent = new Torrent(makeMetadata(), pool);
|
|
31
|
+
|
|
32
|
+
torrent.close();
|
|
33
|
+
|
|
34
|
+
expect(pool.closed).toBe(true);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('starts and closes the download manager when provided', async () => {
|
|
38
|
+
const pool = new FakePeerPool();
|
|
39
|
+
const manager = new FakeDownloadManager();
|
|
40
|
+
const torrent = new Torrent(makeMetadata(), pool, manager);
|
|
41
|
+
|
|
42
|
+
expect(manager.started).toBe(true);
|
|
43
|
+
expect(torrent.done).toBe(manager.done);
|
|
44
|
+
expect(torrent.state).toBe(TorrentState.DOWNLOADING);
|
|
45
|
+
|
|
46
|
+
manager.resolve();
|
|
47
|
+
await expect(torrent.done).resolves.toBeUndefined();
|
|
48
|
+
expect(torrent.state).toBe(TorrentState.COMPLETED);
|
|
49
|
+
|
|
50
|
+
torrent.close();
|
|
51
|
+
|
|
52
|
+
expect(torrent.state).toBe(TorrentState.CLOSED);
|
|
53
|
+
expect(manager.closed).toBe(true);
|
|
54
|
+
expect(pool.closed).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('emits peer, state, done, and close events', async () => {
|
|
58
|
+
const pool = new FakePeerPool();
|
|
59
|
+
const manager = new FakeDownloadManager();
|
|
60
|
+
const torrent = new Torrent(makeMetadata(), pool, manager);
|
|
61
|
+
const peers: unknown[] = [];
|
|
62
|
+
const states: string[] = [];
|
|
63
|
+
let done = false;
|
|
64
|
+
let closed = false;
|
|
65
|
+
|
|
66
|
+
torrent.on('peer', (session) => peers.push(session));
|
|
67
|
+
torrent.on('state', (change) => states.push(`${change.previous}->${change.state}`));
|
|
68
|
+
torrent.on('done', () => {
|
|
69
|
+
done = true;
|
|
70
|
+
});
|
|
71
|
+
torrent.on('close', () => {
|
|
72
|
+
closed = true;
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
const peer = {};
|
|
76
|
+
pool.addSession(peer);
|
|
77
|
+
manager.resolve();
|
|
78
|
+
await torrent.done;
|
|
79
|
+
|
|
80
|
+
torrent.close();
|
|
81
|
+
|
|
82
|
+
expect(peers).toEqual([peer]);
|
|
83
|
+
expect(states).toEqual(['downloading->completed', 'completed->closed']);
|
|
84
|
+
expect(done).toBe(true);
|
|
85
|
+
expect(closed).toBe(true);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('emits errors when download fails', async () => {
|
|
89
|
+
const pool = new FakePeerPool();
|
|
90
|
+
const manager = new FakeDownloadManager();
|
|
91
|
+
const torrent = new Torrent(makeMetadata(), pool, manager);
|
|
92
|
+
const errors: unknown[] = [];
|
|
93
|
+
|
|
94
|
+
torrent.on('error', (error) => errors.push(error));
|
|
95
|
+
|
|
96
|
+
const error = new Error('download failed');
|
|
97
|
+
manager.reject(error);
|
|
98
|
+
await expect(torrent.done).rejects.toBe(error);
|
|
99
|
+
|
|
100
|
+
expect(torrent.state).toBe(TorrentState.FAILED);
|
|
101
|
+
expect(torrent.error).toBe(error);
|
|
102
|
+
expect(errors).toEqual([error]);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('keeps closed state when close resolves the manager', async () => {
|
|
106
|
+
const pool = new FakePeerPool();
|
|
107
|
+
const manager = new FakeDownloadManager();
|
|
108
|
+
const torrent = new Torrent(makeMetadata(), pool, manager);
|
|
109
|
+
const states: string[] = [];
|
|
110
|
+
|
|
111
|
+
torrent.on('state', (change) => states.push(`${change.previous}->${change.state}`));
|
|
112
|
+
torrent.close();
|
|
113
|
+
await torrent.done;
|
|
114
|
+
|
|
115
|
+
expect(torrent.state).toBe(TorrentState.CLOSED);
|
|
116
|
+
expect(states).toEqual(['downloading->closed']);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('returns and emits download progress', () => {
|
|
120
|
+
const pool = new FakePeerPool();
|
|
121
|
+
const manager = new FakeDownloadManager();
|
|
122
|
+
const torrent = new Torrent(makeMetadata(), pool, manager);
|
|
123
|
+
const events: DownloadProgress[] = [];
|
|
124
|
+
|
|
125
|
+
torrent.on('progress', (progress) => events.push(progress));
|
|
126
|
+
|
|
127
|
+
manager.progress = {
|
|
128
|
+
totalBytes: 16_384,
|
|
129
|
+
receivedBytes: 8_192,
|
|
130
|
+
downloadedBytes: 0,
|
|
131
|
+
totalPieces: 1,
|
|
132
|
+
completedPieces: 0,
|
|
133
|
+
percent: 0,
|
|
134
|
+
speedBytesPerSecond: 1_000,
|
|
135
|
+
speed: '1.0 KBps',
|
|
136
|
+
};
|
|
137
|
+
manager.emitProgress();
|
|
138
|
+
|
|
139
|
+
expect(torrent.progress).toEqual(manager.progress);
|
|
140
|
+
expect(events).toEqual([manager.progress]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('returns all files as included when no file selection is provided', () => {
|
|
144
|
+
const pool = new FakePeerPool();
|
|
145
|
+
const metadata = makeMultiFileMetadata();
|
|
146
|
+
const torrent = new Torrent(metadata, pool);
|
|
147
|
+
|
|
148
|
+
expect(torrent.files).toEqual({
|
|
149
|
+
included: metadata.files,
|
|
150
|
+
excluded: [],
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('splits selected files into included and excluded lists', () => {
|
|
155
|
+
const pool = new FakePeerPool();
|
|
156
|
+
const metadata = makeMultiFileMetadata();
|
|
157
|
+
const torrent = new Torrent(metadata, pool, undefined, new Set(['video.mp4']));
|
|
158
|
+
|
|
159
|
+
expect(torrent.files).toEqual({
|
|
160
|
+
included: [{ path: ['video.mp4'], length: 8, offset: 2 }],
|
|
161
|
+
excluded: [
|
|
162
|
+
{ path: ['subtitle.srt'], length: 2, offset: 0 },
|
|
163
|
+
{ path: ['poster.jpg'], length: 4, offset: 10 },
|
|
164
|
+
],
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
class FakePeerPool {
|
|
170
|
+
public done = Promise.resolve([]);
|
|
171
|
+
public totalPeers = 10;
|
|
172
|
+
public size = 1;
|
|
173
|
+
public connecting = 2;
|
|
174
|
+
public attempted = 3;
|
|
175
|
+
public failed = 4;
|
|
176
|
+
public targetConnections = 5;
|
|
177
|
+
public closed = false;
|
|
178
|
+
private readonly sessions: unknown[] = [];
|
|
179
|
+
private readonly sessionListeners = new Set<(session: unknown) => void>();
|
|
180
|
+
|
|
181
|
+
public onSession(callback: (session: unknown) => void): () => void {
|
|
182
|
+
this.sessionListeners.add(callback);
|
|
183
|
+
for (const session of this.sessions) callback(session);
|
|
184
|
+
|
|
185
|
+
return () => {
|
|
186
|
+
this.sessionListeners.delete(callback);
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
public close(): void {
|
|
191
|
+
this.closed = true;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
public addSession(session: unknown): void {
|
|
195
|
+
this.sessions.push(session);
|
|
196
|
+
for (const listener of this.sessionListeners) listener(session);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
class FakeDownloadManager {
|
|
201
|
+
public started = false;
|
|
202
|
+
public closed = false;
|
|
203
|
+
public done: Promise<void>;
|
|
204
|
+
public progress: DownloadProgress = {
|
|
205
|
+
totalBytes: 16_384,
|
|
206
|
+
receivedBytes: 0,
|
|
207
|
+
downloadedBytes: 0,
|
|
208
|
+
totalPieces: 1,
|
|
209
|
+
completedPieces: 0,
|
|
210
|
+
percent: 0,
|
|
211
|
+
speedBytesPerSecond: 0,
|
|
212
|
+
speed: '0.0 Bps',
|
|
213
|
+
};
|
|
214
|
+
private readonly progressListeners = new Set<DownloadProgressListener>();
|
|
215
|
+
private resolveDone!: () => void;
|
|
216
|
+
private rejectDone!: (error: unknown) => void;
|
|
217
|
+
|
|
218
|
+
public constructor() {
|
|
219
|
+
this.done = new Promise((resolve, reject) => {
|
|
220
|
+
this.resolveDone = resolve;
|
|
221
|
+
this.rejectDone = reject;
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
public start(): void {
|
|
226
|
+
this.started = true;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
public close(): void {
|
|
230
|
+
this.closed = true;
|
|
231
|
+
this.resolveDone();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
public onProgress(listener: DownloadProgressListener): () => void {
|
|
235
|
+
this.progressListeners.add(listener);
|
|
236
|
+
|
|
237
|
+
return () => {
|
|
238
|
+
this.progressListeners.delete(listener);
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
public emitProgress(): void {
|
|
243
|
+
for (const listener of this.progressListeners) listener(this.progress);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
public resolve(): void {
|
|
247
|
+
this.resolveDone();
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
public reject(error: unknown): void {
|
|
251
|
+
this.rejectDone(error);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const makeMetadata = (): TorrentMetadata => ({
|
|
256
|
+
announce: 'udp://tracker.test:80',
|
|
257
|
+
announceList: [],
|
|
258
|
+
infoHash: new Uint8Array(20),
|
|
259
|
+
name: 'file.bin',
|
|
260
|
+
pieceLength: 16_384,
|
|
261
|
+
pieces: [new Uint8Array(20)],
|
|
262
|
+
length: 16_384,
|
|
263
|
+
files: [{ path: ['file.bin'], length: 16_384, offset: 0 }],
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
const makeMultiFileMetadata = (): TorrentMetadata => ({
|
|
267
|
+
announce: 'udp://tracker.test:80',
|
|
268
|
+
announceList: [],
|
|
269
|
+
infoHash: new Uint8Array(20),
|
|
270
|
+
name: 'download',
|
|
271
|
+
pieceLength: 4,
|
|
272
|
+
pieces: [new Uint8Array(20), new Uint8Array(20), new Uint8Array(20), new Uint8Array(20)],
|
|
273
|
+
length: 14,
|
|
274
|
+
files: [
|
|
275
|
+
{ path: ['subtitle.srt'], length: 2, offset: 0 },
|
|
276
|
+
{ path: ['video.mp4'], length: 8, offset: 2 },
|
|
277
|
+
{ path: ['poster.jpg'], length: 4, offset: 10 },
|
|
278
|
+
],
|
|
279
|
+
});
|