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,257 @@
|
|
|
1
|
+
import type { TorrentMetadata } from '@torrent/types';
|
|
2
|
+
import type {
|
|
3
|
+
BlockState,
|
|
4
|
+
PieceAvailability,
|
|
5
|
+
PieceBlock,
|
|
6
|
+
PieceBlockRequest,
|
|
7
|
+
PieceCompletion,
|
|
8
|
+
PiecePlanner,
|
|
9
|
+
PiecePlannerOptions,
|
|
10
|
+
PieceProgress,
|
|
11
|
+
PlannedPiece,
|
|
12
|
+
} from './types';
|
|
13
|
+
import { getPieceLength, isValidBlockForRequest, splitPieceIntoRequests } from './utils';
|
|
14
|
+
import { concatBytes } from '@utils/buffers';
|
|
15
|
+
import { PiecePlannerError, PiecePlannerErrorCode } from './planner.error';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* In-memory piece planner that schedules block requests and assembles completed pieces.
|
|
19
|
+
*/
|
|
20
|
+
export class DefaultPiecePlanner implements PiecePlanner {
|
|
21
|
+
private pieces: PlannedPiece[];
|
|
22
|
+
private selectedPieces: Set<number>;
|
|
23
|
+
private selectedPieceList: number[];
|
|
24
|
+
private completed = new Set<number>();
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Build an initial missing-block plan from torrent metadata.
|
|
28
|
+
*
|
|
29
|
+
* @param metadata - Parsed torrent metadata containing piece hashes and sizes.
|
|
30
|
+
* @param options - Planner options, including optional block size.
|
|
31
|
+
* @throws {PiecePlannerError} When piece or block sizing is invalid.
|
|
32
|
+
*/
|
|
33
|
+
constructor(metadata: TorrentMetadata, options: PiecePlannerOptions) {
|
|
34
|
+
this.pieces = metadata.pieces.map((expectedHash, pieceIndex) => {
|
|
35
|
+
const length = getPieceLength(metadata, pieceIndex);
|
|
36
|
+
return {
|
|
37
|
+
pieceIndex,
|
|
38
|
+
expectedHash,
|
|
39
|
+
length,
|
|
40
|
+
blocks: splitPieceIntoRequests({
|
|
41
|
+
pieceIndex,
|
|
42
|
+
pieceLength: length,
|
|
43
|
+
blockLength: options.blockLength,
|
|
44
|
+
}).map((request) => ({
|
|
45
|
+
request,
|
|
46
|
+
status: 'missing',
|
|
47
|
+
data: undefined,
|
|
48
|
+
})),
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
this.selectedPieceList =
|
|
52
|
+
options.pieceIndexes === undefined
|
|
53
|
+
? this.pieces.map((piece) => piece.pieceIndex)
|
|
54
|
+
: [...new Set(options.pieceIndexes)].sort((a, b) => a - b);
|
|
55
|
+
this.selectedPieces = new Set(this.selectedPieceList);
|
|
56
|
+
|
|
57
|
+
for (const pieceIndex of this.selectedPieceList) this.getPiece(pieceIndex);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
get complete(): boolean {
|
|
61
|
+
return this.completed.size === this.selectedPieces.size;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
get completedPieces(): number {
|
|
65
|
+
return this.completed.size;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
get pieceIndexes(): readonly number[] {
|
|
69
|
+
return this.selectedPieceList;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
get totalPieces(): number {
|
|
73
|
+
return this.selectedPieces.size;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Return internal state for a piece.
|
|
78
|
+
*
|
|
79
|
+
* @param pieceIndex - Zero-based piece index.
|
|
80
|
+
* @returns Planned piece state.
|
|
81
|
+
* @throws {PiecePlannerError} When `pieceIndex` is outside the torrent piece range.
|
|
82
|
+
*/
|
|
83
|
+
private getPiece(pieceIndex: number): PlannedPiece {
|
|
84
|
+
const piece = this.pieces[pieceIndex];
|
|
85
|
+
if (!piece) {
|
|
86
|
+
throw new PiecePlannerError(
|
|
87
|
+
PiecePlannerErrorCode.INVALID_PIECE_INDEX,
|
|
88
|
+
`Unknown piece index: ${pieceIndex}`,
|
|
89
|
+
pieceIndex,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return piece;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Locate the internal block state described by a request.
|
|
97
|
+
*
|
|
98
|
+
* @param request - Block request to look up.
|
|
99
|
+
* @returns Matching block state, or `undefined` when the request is unknown.
|
|
100
|
+
* @throws {PiecePlannerError} When `request.pieceIndex` is invalid.
|
|
101
|
+
*/
|
|
102
|
+
private findBlock(request: PieceBlockRequest): BlockState | undefined {
|
|
103
|
+
const piece = this.getPiece(request.pieceIndex);
|
|
104
|
+
return piece.blocks.find(
|
|
105
|
+
(b) => b.request.offset === request.offset && b.request.length === request.length,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Return a progress snapshot for a piece.
|
|
111
|
+
*
|
|
112
|
+
* @param pieceIndex - Zero-based piece index.
|
|
113
|
+
* @returns Progress for the requested piece.
|
|
114
|
+
* @throws {PiecePlannerError} When `pieceIndex` is outside the torrent piece range.
|
|
115
|
+
*/
|
|
116
|
+
public getProgress(pieceIndex: number): PieceProgress {
|
|
117
|
+
const piece = this.getPiece(pieceIndex);
|
|
118
|
+
const completed = this.completed.has(pieceIndex);
|
|
119
|
+
const receivedBytes = piece.blocks.reduce<number>(
|
|
120
|
+
(a, b) => a + (b.status === 'received' ? b.request.length : 0),
|
|
121
|
+
0,
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
pieceIndex,
|
|
126
|
+
length: piece.length,
|
|
127
|
+
receivedBytes,
|
|
128
|
+
status: completed
|
|
129
|
+
? 'complete'
|
|
130
|
+
: piece.blocks.some((b) => b.status === 'pending')
|
|
131
|
+
? 'pending'
|
|
132
|
+
: 'missing',
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Pick the next missing block request.
|
|
138
|
+
*
|
|
139
|
+
* @param availablePieces - Optional peer availability filter, usually backed by bitfield/have state.
|
|
140
|
+
* @returns The next request to send, or `undefined` when nothing is currently requestable.
|
|
141
|
+
*/
|
|
142
|
+
public nextRequest(availablePieces?: PieceAvailability): PieceBlockRequest | undefined {
|
|
143
|
+
for (const piece of this.pieces) {
|
|
144
|
+
if (!this.selectedPieces.has(piece.pieceIndex)) continue;
|
|
145
|
+
if (this.completed.has(piece.pieceIndex)) continue;
|
|
146
|
+
if (availablePieces && !availablePieces.hasPiece(piece.pieceIndex)) continue;
|
|
147
|
+
|
|
148
|
+
const block = piece.blocks.find((b) => b.status === 'missing');
|
|
149
|
+
if (block) return block.request;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Mark a missing request as in-flight.
|
|
157
|
+
*
|
|
158
|
+
* @param request - Request selected by `nextRequest`.
|
|
159
|
+
* @throws {PiecePlannerError} When `request.pieceIndex` is invalid.
|
|
160
|
+
*/
|
|
161
|
+
public markPending(request: PieceBlockRequest): void {
|
|
162
|
+
const block = this.findBlock(request);
|
|
163
|
+
if (!block || block.status !== 'missing') return;
|
|
164
|
+
|
|
165
|
+
block.status = 'pending';
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Return an in-flight request back to the missing state.
|
|
170
|
+
*
|
|
171
|
+
* @param request - Pending request that timed out or belonged to a disconnected peer.
|
|
172
|
+
* @throws {PiecePlannerError} When `request.pieceIndex` is invalid.
|
|
173
|
+
*/
|
|
174
|
+
public resetPending(request: PieceBlockRequest): void {
|
|
175
|
+
const block = this.findBlock(request);
|
|
176
|
+
if (!block || block.status !== 'pending') return;
|
|
177
|
+
|
|
178
|
+
block.status = 'missing';
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Reset every in-flight request assigned to a disconnected peer.
|
|
183
|
+
*
|
|
184
|
+
* @param requests - Requests previously assigned to the peer.
|
|
185
|
+
* @throws {PiecePlannerError} When any request contains an invalid piece index.
|
|
186
|
+
*/
|
|
187
|
+
public resetPeerRequests(requests: Iterable<PieceBlockRequest>): void {
|
|
188
|
+
for (const r of requests) this.resetPending(r);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Concatenate received blocks for a completed piece.
|
|
193
|
+
*
|
|
194
|
+
* @param piece - Planned piece whose blocks should all be received.
|
|
195
|
+
* @returns The assembled piece bytes.
|
|
196
|
+
* @throws {PiecePlannerError} When any block payload is missing.
|
|
197
|
+
*/
|
|
198
|
+
private assemblePiece(piece: PlannedPiece): Uint8Array {
|
|
199
|
+
const blocks: Uint8Array[] = [];
|
|
200
|
+
|
|
201
|
+
for (const block of piece.blocks) {
|
|
202
|
+
if (!block.data) {
|
|
203
|
+
throw new PiecePlannerError(
|
|
204
|
+
PiecePlannerErrorCode.INCOMPLETE_PIECE,
|
|
205
|
+
`Cannot assemble incomplete piece: ${piece.pieceIndex}`,
|
|
206
|
+
piece.pieceIndex,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
blocks.push(block.data);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return concatBytes(blocks);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Accept a received block and emit a completed piece once all blocks are present.
|
|
217
|
+
*
|
|
218
|
+
* @param block - Received peer piece message.
|
|
219
|
+
* @returns Completed piece data, or `undefined` if the piece is still incomplete or the block is unknown.
|
|
220
|
+
* @throws {PiecePlannerError} When `block.pieceIndex` is invalid.
|
|
221
|
+
*/
|
|
222
|
+
public receiveBlock(block: PieceBlock): PieceCompletion | undefined {
|
|
223
|
+
const piece = this.getPiece(block.pieceIndex);
|
|
224
|
+
const state = piece.blocks.find((b) => isValidBlockForRequest(block, b.request));
|
|
225
|
+
|
|
226
|
+
if (!state || state.status !== 'pending') return undefined;
|
|
227
|
+
|
|
228
|
+
state.status = 'received';
|
|
229
|
+
state.data = block.block;
|
|
230
|
+
|
|
231
|
+
if (!piece.blocks.every((block) => block.status === 'received')) return undefined;
|
|
232
|
+
|
|
233
|
+
this.completed.add(piece.pieceIndex);
|
|
234
|
+
|
|
235
|
+
return {
|
|
236
|
+
pieceIndex: block.pieceIndex,
|
|
237
|
+
data: this.assemblePiece(piece),
|
|
238
|
+
expectedHash: piece.expectedHash,
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Return every block in a piece to the missing state.
|
|
244
|
+
*
|
|
245
|
+
* @param pieceIndex - Piece to retry after failed validation or storage.
|
|
246
|
+
* @throws {PiecePlannerError} When `pieceIndex` is outside the torrent piece range.
|
|
247
|
+
*/
|
|
248
|
+
public resetPiece(pieceIndex: number): void {
|
|
249
|
+
const piece = this.getPiece(pieceIndex);
|
|
250
|
+
this.completed.delete(pieceIndex);
|
|
251
|
+
|
|
252
|
+
for (const block of piece.blocks) {
|
|
253
|
+
block.status = 'missing';
|
|
254
|
+
block.data = undefined;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export { createPiecePlanner } from './planner';
|
|
2
|
+
export {
|
|
3
|
+
DEFAULT_BLOCK_LENGTH,
|
|
4
|
+
getPieceLength,
|
|
5
|
+
isValidBlockForRequest,
|
|
6
|
+
splitPieceIntoRequests,
|
|
7
|
+
} from './utils';
|
|
8
|
+
export { PiecePlannerError, PiecePlannerErrorCode } from './planner.error';
|
|
9
|
+
export { validatePiece, type PieceValidationResult } from './validation';
|
|
10
|
+
|
|
11
|
+
export type {
|
|
12
|
+
PieceAvailability,
|
|
13
|
+
PieceBlock,
|
|
14
|
+
PieceBlockRequest,
|
|
15
|
+
PieceCompletion,
|
|
16
|
+
PiecePlanner,
|
|
17
|
+
PiecePlannerOptions,
|
|
18
|
+
PieceProgress,
|
|
19
|
+
PieceStatus,
|
|
20
|
+
} from './types';
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { BunTorrentError } from '@utils/errors';
|
|
2
|
+
|
|
3
|
+
export enum PiecePlannerErrorCode {
|
|
4
|
+
INCOMPLETE_PIECE = 'PIECE_PLANNER_INCOMPLETE_PIECE',
|
|
5
|
+
INVALID_BLOCK_LENGTH = 'PIECE_PLANNER_INVALID_BLOCK_LENGTH',
|
|
6
|
+
INVALID_PIECE_INDEX = 'PIECE_PLANNER_INVALID_PIECE_INDEX',
|
|
7
|
+
INVALID_PIECE_LENGTH = 'PIECE_PLANNER_INVALID_PIECE_LENGTH',
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class PiecePlannerError extends BunTorrentError {
|
|
11
|
+
constructor(
|
|
12
|
+
code: PiecePlannerErrorCode,
|
|
13
|
+
message: string,
|
|
14
|
+
public readonly pieceIndex?: number,
|
|
15
|
+
) {
|
|
16
|
+
super(message, code);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import type { TorrentMetadata } from '@torrent/types';
|
|
4
|
+
import { createPiecePlanner } from './planner';
|
|
5
|
+
import { PiecePlannerError, PiecePlannerErrorCode } from './planner.error';
|
|
6
|
+
import { getPieceLength, isValidBlockForRequest, splitPieceIntoRequests } from './utils';
|
|
7
|
+
|
|
8
|
+
const makeMetadata = ({
|
|
9
|
+
length = 1_000,
|
|
10
|
+
pieceLength = 256,
|
|
11
|
+
pieces = 4,
|
|
12
|
+
}: {
|
|
13
|
+
length?: number;
|
|
14
|
+
pieceLength?: number;
|
|
15
|
+
pieces?: number;
|
|
16
|
+
} = {}): TorrentMetadata => ({
|
|
17
|
+
announceList: [],
|
|
18
|
+
infoHash: new Uint8Array(20),
|
|
19
|
+
name: 'file.bin',
|
|
20
|
+
pieceLength,
|
|
21
|
+
pieces: Array.from({ length: pieces }, (_, index) => new Uint8Array(20).fill(index)),
|
|
22
|
+
length,
|
|
23
|
+
files: [{ path: ['file.bin'], length, offset: 0 }],
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const expectPlannerError = (callback: () => unknown, code: PiecePlannerErrorCode): void => {
|
|
27
|
+
try {
|
|
28
|
+
callback();
|
|
29
|
+
throw new Error('Expected callback to throw');
|
|
30
|
+
} catch (error) {
|
|
31
|
+
expect(error).toBeInstanceOf(PiecePlannerError);
|
|
32
|
+
expect((error as PiecePlannerError).code).toBe(code);
|
|
33
|
+
}
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
describe('piece planner utils', () => {
|
|
37
|
+
test('gets regular and final piece lengths', () => {
|
|
38
|
+
const metadata = makeMetadata();
|
|
39
|
+
|
|
40
|
+
expect(getPieceLength(metadata, 0)).toBe(256);
|
|
41
|
+
expect(getPieceLength(metadata, 1)).toBe(256);
|
|
42
|
+
expect(getPieceLength(metadata, 2)).toBe(256);
|
|
43
|
+
expect(getPieceLength(metadata, 3)).toBe(232);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test('rejects invalid piece indexes', () => {
|
|
47
|
+
const metadata = makeMetadata();
|
|
48
|
+
|
|
49
|
+
expectPlannerError(
|
|
50
|
+
() => getPieceLength(metadata, -1),
|
|
51
|
+
PiecePlannerErrorCode.INVALID_PIECE_INDEX,
|
|
52
|
+
);
|
|
53
|
+
expectPlannerError(
|
|
54
|
+
() => getPieceLength(metadata, 4),
|
|
55
|
+
PiecePlannerErrorCode.INVALID_PIECE_INDEX,
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('splits a piece into block requests', () => {
|
|
60
|
+
expect(
|
|
61
|
+
splitPieceIntoRequests({
|
|
62
|
+
pieceIndex: 3,
|
|
63
|
+
pieceLength: 40_000,
|
|
64
|
+
blockLength: 16_384,
|
|
65
|
+
}),
|
|
66
|
+
).toEqual([
|
|
67
|
+
{ type: 'request', pieceIndex: 3, offset: 0, length: 16_384 },
|
|
68
|
+
{ type: 'request', pieceIndex: 3, offset: 16_384, length: 16_384 },
|
|
69
|
+
{ type: 'request', pieceIndex: 3, offset: 32_768, length: 7_232 },
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('rejects invalid piece and block lengths', () => {
|
|
74
|
+
expectPlannerError(
|
|
75
|
+
() => splitPieceIntoRequests({ pieceIndex: 0, pieceLength: 0 }),
|
|
76
|
+
PiecePlannerErrorCode.INVALID_PIECE_LENGTH,
|
|
77
|
+
);
|
|
78
|
+
expectPlannerError(
|
|
79
|
+
() => splitPieceIntoRequests({ pieceIndex: 0, pieceLength: 10, blockLength: 0 }),
|
|
80
|
+
PiecePlannerErrorCode.INVALID_BLOCK_LENGTH,
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test('checks whether a block satisfies a request', () => {
|
|
85
|
+
const request = { type: 'request' as const, pieceIndex: 1, offset: 4, length: 3 };
|
|
86
|
+
|
|
87
|
+
expect(
|
|
88
|
+
isValidBlockForRequest(
|
|
89
|
+
{ type: 'piece', pieceIndex: 1, offset: 4, block: new Uint8Array(3) },
|
|
90
|
+
request,
|
|
91
|
+
),
|
|
92
|
+
).toBe(true);
|
|
93
|
+
expect(
|
|
94
|
+
isValidBlockForRequest(
|
|
95
|
+
{ type: 'piece', pieceIndex: 1, offset: 5, block: new Uint8Array(3) },
|
|
96
|
+
request,
|
|
97
|
+
),
|
|
98
|
+
).toBe(false);
|
|
99
|
+
expect(
|
|
100
|
+
isValidBlockForRequest(
|
|
101
|
+
{ type: 'piece', pieceIndex: 1, offset: 4, block: new Uint8Array(2) },
|
|
102
|
+
request,
|
|
103
|
+
),
|
|
104
|
+
).toBe(false);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe('DefaultPiecePlanner', () => {
|
|
109
|
+
test('tracks totals and initial progress', () => {
|
|
110
|
+
const planner = createPiecePlanner(
|
|
111
|
+
makeMetadata({ length: 10, pieceLength: 4, pieces: 3 }),
|
|
112
|
+
{
|
|
113
|
+
blockLength: 2,
|
|
114
|
+
},
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
expect(planner.totalPieces).toBe(3);
|
|
118
|
+
expect(planner.completedPieces).toBe(0);
|
|
119
|
+
expect(planner.complete).toBe(false);
|
|
120
|
+
expect(planner.getProgress(0)).toEqual({
|
|
121
|
+
pieceIndex: 0,
|
|
122
|
+
length: 4,
|
|
123
|
+
receivedBytes: 0,
|
|
124
|
+
status: 'missing',
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('returns the next missing request and respects availability', () => {
|
|
129
|
+
const planner = createPiecePlanner(makeMetadata({ length: 8, pieceLength: 4, pieces: 2 }), {
|
|
130
|
+
blockLength: 2,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
expect(
|
|
134
|
+
planner.nextRequest({
|
|
135
|
+
hasPiece: (pieceIndex) => pieceIndex === 1,
|
|
136
|
+
}),
|
|
137
|
+
).toEqual({ type: 'request', pieceIndex: 1, offset: 0, length: 2 });
|
|
138
|
+
|
|
139
|
+
const request = planner.nextRequest();
|
|
140
|
+
expect(request).toEqual({ type: 'request', pieceIndex: 0, offset: 0, length: 2 });
|
|
141
|
+
|
|
142
|
+
planner.markPending(request!);
|
|
143
|
+
expect(planner.nextRequest()).toEqual({
|
|
144
|
+
type: 'request',
|
|
145
|
+
pieceIndex: 0,
|
|
146
|
+
offset: 2,
|
|
147
|
+
length: 2,
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test('schedules only selected pieces when configured', () => {
|
|
152
|
+
const planner = createPiecePlanner(
|
|
153
|
+
makeMetadata({ length: 12, pieceLength: 4, pieces: 3 }),
|
|
154
|
+
{
|
|
155
|
+
pieceIndexes: [1],
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
expect(planner.totalPieces).toBe(1);
|
|
160
|
+
expect(planner.pieceIndexes).toEqual([1]);
|
|
161
|
+
expect(planner.nextRequest()).toEqual({
|
|
162
|
+
type: 'request',
|
|
163
|
+
pieceIndex: 1,
|
|
164
|
+
offset: 0,
|
|
165
|
+
length: 4,
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('resets pending requests', () => {
|
|
170
|
+
const planner = createPiecePlanner(makeMetadata({ length: 4, pieceLength: 4, pieces: 1 }), {
|
|
171
|
+
blockLength: 2,
|
|
172
|
+
});
|
|
173
|
+
const first = planner.nextRequest()!;
|
|
174
|
+
const second = { type: 'request' as const, pieceIndex: 0, offset: 2, length: 2 };
|
|
175
|
+
|
|
176
|
+
planner.markPending(first);
|
|
177
|
+
planner.markPending(second);
|
|
178
|
+
expect(planner.nextRequest()).toBeUndefined();
|
|
179
|
+
|
|
180
|
+
planner.resetPending(first);
|
|
181
|
+
expect(planner.nextRequest()).toEqual(first);
|
|
182
|
+
|
|
183
|
+
planner.markPending(first);
|
|
184
|
+
planner.resetPeerRequests([first, second]);
|
|
185
|
+
expect(planner.nextRequest()).toEqual(first);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('ignores blocks that were not requested', () => {
|
|
189
|
+
const planner = createPiecePlanner(makeMetadata({ length: 4, pieceLength: 4, pieces: 1 }), {
|
|
190
|
+
blockLength: 4,
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
expect(
|
|
194
|
+
planner.receiveBlock({
|
|
195
|
+
type: 'piece',
|
|
196
|
+
pieceIndex: 0,
|
|
197
|
+
offset: 0,
|
|
198
|
+
block: new Uint8Array([1, 2, 3, 4]),
|
|
199
|
+
}),
|
|
200
|
+
).toBeUndefined();
|
|
201
|
+
expect(planner.getProgress(0).receivedBytes).toBe(0);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('returns completion after all blocks in a piece are received', () => {
|
|
205
|
+
const planner = createPiecePlanner(makeMetadata({ length: 4, pieceLength: 4, pieces: 1 }), {
|
|
206
|
+
blockLength: 2,
|
|
207
|
+
});
|
|
208
|
+
const first = planner.nextRequest()!;
|
|
209
|
+
planner.markPending(first);
|
|
210
|
+
const second = planner.nextRequest()!;
|
|
211
|
+
planner.markPending(second);
|
|
212
|
+
|
|
213
|
+
expect(
|
|
214
|
+
planner.receiveBlock({
|
|
215
|
+
type: 'piece',
|
|
216
|
+
pieceIndex: 0,
|
|
217
|
+
offset: 0,
|
|
218
|
+
block: new Uint8Array([1, 2]),
|
|
219
|
+
}),
|
|
220
|
+
).toBeUndefined();
|
|
221
|
+
|
|
222
|
+
const completion = planner.receiveBlock({
|
|
223
|
+
type: 'piece',
|
|
224
|
+
pieceIndex: 0,
|
|
225
|
+
offset: 2,
|
|
226
|
+
block: new Uint8Array([3, 4]),
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
expect(completion).toEqual({
|
|
230
|
+
pieceIndex: 0,
|
|
231
|
+
data: new Uint8Array([1, 2, 3, 4]),
|
|
232
|
+
expectedHash: new Uint8Array(20),
|
|
233
|
+
});
|
|
234
|
+
expect(planner.complete).toBe(true);
|
|
235
|
+
expect(planner.completedPieces).toBe(1);
|
|
236
|
+
expect(planner.getProgress(0)).toEqual({
|
|
237
|
+
pieceIndex: 0,
|
|
238
|
+
length: 4,
|
|
239
|
+
receivedBytes: 4,
|
|
240
|
+
status: 'complete',
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('resets a completed piece for retry', () => {
|
|
245
|
+
const planner = createPiecePlanner(makeMetadata({ length: 4, pieceLength: 4, pieces: 1 }), {
|
|
246
|
+
blockLength: 2,
|
|
247
|
+
});
|
|
248
|
+
const first = planner.nextRequest()!;
|
|
249
|
+
planner.markPending(first);
|
|
250
|
+
const second = planner.nextRequest()!;
|
|
251
|
+
planner.markPending(second);
|
|
252
|
+
planner.receiveBlock({
|
|
253
|
+
type: 'piece',
|
|
254
|
+
pieceIndex: 0,
|
|
255
|
+
offset: 0,
|
|
256
|
+
block: new Uint8Array([1, 2]),
|
|
257
|
+
});
|
|
258
|
+
planner.receiveBlock({
|
|
259
|
+
type: 'piece',
|
|
260
|
+
pieceIndex: 0,
|
|
261
|
+
offset: 2,
|
|
262
|
+
block: new Uint8Array([3, 4]),
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
planner.resetPiece(0);
|
|
266
|
+
|
|
267
|
+
expect(planner.complete).toBe(false);
|
|
268
|
+
expect(planner.completedPieces).toBe(0);
|
|
269
|
+
expect(planner.getProgress(0)).toEqual({
|
|
270
|
+
pieceIndex: 0,
|
|
271
|
+
length: 4,
|
|
272
|
+
receivedBytes: 0,
|
|
273
|
+
status: 'missing',
|
|
274
|
+
});
|
|
275
|
+
expect(planner.nextRequest()).toEqual(first);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('resets a partially received piece for retry', () => {
|
|
279
|
+
const planner = createPiecePlanner(makeMetadata({ length: 4, pieceLength: 4, pieces: 1 }), {
|
|
280
|
+
blockLength: 2,
|
|
281
|
+
});
|
|
282
|
+
const first = planner.nextRequest()!;
|
|
283
|
+
planner.markPending(first);
|
|
284
|
+
planner.receiveBlock({
|
|
285
|
+
type: 'piece',
|
|
286
|
+
pieceIndex: 0,
|
|
287
|
+
offset: 0,
|
|
288
|
+
block: new Uint8Array([1, 2]),
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
planner.resetPiece(0);
|
|
292
|
+
|
|
293
|
+
expect(planner.getProgress(0).receivedBytes).toBe(0);
|
|
294
|
+
expect(planner.nextRequest()).toEqual(first);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
test('throws planner errors for invalid piece indexes', () => {
|
|
298
|
+
const planner = createPiecePlanner(makeMetadata({ length: 4, pieceLength: 4, pieces: 1 }));
|
|
299
|
+
|
|
300
|
+
expectPlannerError(() => planner.getProgress(1), PiecePlannerErrorCode.INVALID_PIECE_INDEX);
|
|
301
|
+
expectPlannerError(() => planner.resetPiece(1), PiecePlannerErrorCode.INVALID_PIECE_INDEX);
|
|
302
|
+
});
|
|
303
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { TorrentMetadata } from '@torrent/types';
|
|
2
|
+
import type { PiecePlanner, PiecePlannerOptions } from './types';
|
|
3
|
+
import { DefaultPiecePlanner } from './DefaultPlanner';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Create the default mutable piece planner for torrent download scheduling.
|
|
7
|
+
*
|
|
8
|
+
* @param metadata - Parsed torrent metadata containing piece hashes and sizing information.
|
|
9
|
+
* @param options - Planner configuration such as block size.
|
|
10
|
+
* @returns A planner that can schedule block requests and assemble completed pieces.
|
|
11
|
+
* @throws {PiecePlannerError} When metadata implies invalid piece or block sizing.
|
|
12
|
+
*/
|
|
13
|
+
export const createPiecePlanner = (
|
|
14
|
+
metadata: TorrentMetadata,
|
|
15
|
+
options: PiecePlannerOptions = {},
|
|
16
|
+
): PiecePlanner => new DefaultPiecePlanner(metadata, options);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { PieceMessage, RequestMessage } from '@peer/messages';
|
|
2
|
+
|
|
3
|
+
export type PiecePlannerOptions = {
|
|
4
|
+
blockLength?: number;
|
|
5
|
+
pieceIndexes?: Iterable<number>;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type PieceBlockRequest = RequestMessage;
|
|
9
|
+
|
|
10
|
+
export type PieceBlock = PieceMessage;
|
|
11
|
+
|
|
12
|
+
export type PieceStatus = 'missing' | 'pending' | 'complete';
|
|
13
|
+
|
|
14
|
+
export type PieceProgress = {
|
|
15
|
+
pieceIndex: number;
|
|
16
|
+
length: number;
|
|
17
|
+
receivedBytes: number;
|
|
18
|
+
status: PieceStatus;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type PiecePlanner = {
|
|
22
|
+
readonly complete: boolean;
|
|
23
|
+
readonly completedPieces: number;
|
|
24
|
+
readonly pieceIndexes: readonly number[];
|
|
25
|
+
readonly totalPieces: number;
|
|
26
|
+
|
|
27
|
+
getProgress(pieceIndex: number): PieceProgress;
|
|
28
|
+
nextRequest(availablePieces?: PieceAvailability): PieceBlockRequest | undefined;
|
|
29
|
+
markPending(request: PieceBlockRequest): void;
|
|
30
|
+
receiveBlock(block: PieceBlock): PieceCompletion | undefined;
|
|
31
|
+
resetPiece(pieceIndex: number): void;
|
|
32
|
+
resetPending(request: PieceBlockRequest): void;
|
|
33
|
+
resetPeerRequests(requests: Iterable<PieceBlockRequest>): void;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type PieceAvailability = {
|
|
37
|
+
hasPiece(pieceIndex: number): boolean;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type PieceCompletion = {
|
|
41
|
+
pieceIndex: number;
|
|
42
|
+
data: Uint8Array;
|
|
43
|
+
expectedHash: Uint8Array;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export type BlockStatus = 'missing' | 'pending' | 'received';
|
|
47
|
+
|
|
48
|
+
export type BlockState = {
|
|
49
|
+
request: PieceBlockRequest;
|
|
50
|
+
status: BlockStatus;
|
|
51
|
+
data?: Uint8Array;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type PlannedPiece = {
|
|
55
|
+
pieceIndex: number;
|
|
56
|
+
length: number;
|
|
57
|
+
expectedHash: Uint8Array;
|
|
58
|
+
blocks: BlockState[];
|
|
59
|
+
};
|