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,231 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import { BunTorrentError } from '@utils/errors';
|
|
4
|
+
import { PeerMessageError, PeerMessageErrorCode, decodePeerMessage, encodePeerMessage } from '.';
|
|
5
|
+
import { readUInt32, writeUInt32 } from './helpers';
|
|
6
|
+
|
|
7
|
+
const expectPeerMessageError = (callback: () => unknown, code: PeerMessageErrorCode): void => {
|
|
8
|
+
try {
|
|
9
|
+
callback();
|
|
10
|
+
throw new Error('Expected peer message operation to throw');
|
|
11
|
+
} catch (error) {
|
|
12
|
+
expect(error).toBeInstanceOf(PeerMessageError);
|
|
13
|
+
expect(error).toBeInstanceOf(BunTorrentError);
|
|
14
|
+
expect((error as PeerMessageError).code).toBe(code);
|
|
15
|
+
}
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
describe('peer message uint32 helpers', () => {
|
|
19
|
+
test('writes uint32 values in big-endian order', () => {
|
|
20
|
+
expect([...writeUInt32(0)]).toEqual([0, 0, 0, 0]);
|
|
21
|
+
expect([...writeUInt32(1)]).toEqual([0, 0, 0, 1]);
|
|
22
|
+
expect([...writeUInt32(13)]).toEqual([0, 0, 0, 13]);
|
|
23
|
+
expect([...writeUInt32(0x01020304)]).toEqual([1, 2, 3, 4]);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('reads uint32 values in big-endian order', () => {
|
|
27
|
+
expect(readUInt32(new Uint8Array([0, 0, 0, 0]), 0)).toBe(0);
|
|
28
|
+
expect(readUInt32(new Uint8Array([0, 0, 0, 13]), 0)).toBe(13);
|
|
29
|
+
expect(readUInt32(new Uint8Array([9, 1, 2, 3, 4]), 1)).toBe(0x01020304);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('rejects invalid uint32 values', () => {
|
|
33
|
+
expectPeerMessageError(() => writeUInt32(-1), PeerMessageErrorCode.INVALID_UINT32);
|
|
34
|
+
expectPeerMessageError(() => writeUInt32(1.5), PeerMessageErrorCode.INVALID_UINT32);
|
|
35
|
+
expectPeerMessageError(() => writeUInt32(0x100000000), PeerMessageErrorCode.INVALID_UINT32);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('rejects reading past the end of the input', () => {
|
|
39
|
+
expectPeerMessageError(
|
|
40
|
+
() => readUInt32(new Uint8Array([0, 0, 0]), 0),
|
|
41
|
+
PeerMessageErrorCode.INVALID_LENGTH_PREFIX,
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
describe('encodePeerMessage', () => {
|
|
47
|
+
test('encodes keep-alive', () => {
|
|
48
|
+
expect([...encodePeerMessage({ type: 'keep-alive' })]).toEqual([0, 0, 0, 0]);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('encodes choke', () => {
|
|
52
|
+
expect([...encodePeerMessage({ type: 'choke' })]).toEqual([0, 0, 0, 1, 0]);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('encodes unchoke', () => {
|
|
56
|
+
expect([...encodePeerMessage({ type: 'unchoke' })]).toEqual([0, 0, 0, 1, 1]);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('encodes interested', () => {
|
|
60
|
+
expect([...encodePeerMessage({ type: 'interested' })]).toEqual([0, 0, 0, 1, 2]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('encodes not-interested', () => {
|
|
64
|
+
expect([...encodePeerMessage({ type: 'not-interested' })]).toEqual([0, 0, 0, 1, 3]);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('encodes have', () => {
|
|
68
|
+
expect([...encodePeerMessage({ type: 'have', pieceIndex: 7 })]).toEqual([
|
|
69
|
+
0, 0, 0, 5, 4, 0, 0, 0, 7,
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('encodes bitfield', () => {
|
|
74
|
+
expect([
|
|
75
|
+
...encodePeerMessage({ type: 'bitfield', bitfield: new Uint8Array([0b10100000]) }),
|
|
76
|
+
]).toEqual([0, 0, 0, 2, 5, 0b10100000]);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('encodes request', () => {
|
|
80
|
+
expect([
|
|
81
|
+
...encodePeerMessage({
|
|
82
|
+
type: 'request',
|
|
83
|
+
pieceIndex: 7,
|
|
84
|
+
offset: 16_384,
|
|
85
|
+
length: 16_384,
|
|
86
|
+
}),
|
|
87
|
+
]).toEqual([0, 0, 0, 13, 6, 0, 0, 0, 7, 0, 0, 64, 0, 0, 0, 64, 0]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('encodes piece', () => {
|
|
91
|
+
expect([
|
|
92
|
+
...encodePeerMessage({
|
|
93
|
+
type: 'piece',
|
|
94
|
+
pieceIndex: 7,
|
|
95
|
+
offset: 16_384,
|
|
96
|
+
block: new Uint8Array([1, 2, 3]),
|
|
97
|
+
}),
|
|
98
|
+
]).toEqual([0, 0, 0, 12, 7, 0, 0, 0, 7, 0, 0, 64, 0, 1, 2, 3]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('encodes cancel', () => {
|
|
102
|
+
expect([
|
|
103
|
+
...encodePeerMessage({ type: 'cancel', pieceIndex: 7, offset: 16_384, length: 16_384 }),
|
|
104
|
+
]).toEqual([0, 0, 0, 13, 8, 0, 0, 0, 7, 0, 0, 64, 0, 0, 0, 64, 0]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('rejects invalid request fields', () => {
|
|
108
|
+
expectPeerMessageError(
|
|
109
|
+
() => encodePeerMessage({ type: 'request', pieceIndex: -1, offset: 0, length: 16_384 }),
|
|
110
|
+
PeerMessageErrorCode.INVALID_UINT32,
|
|
111
|
+
);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('decodePeerMessage', () => {
|
|
116
|
+
test('decodes keep-alive', () => {
|
|
117
|
+
expect(decodePeerMessage(new Uint8Array([0, 0, 0, 0]))).toEqual({ type: 'keep-alive' });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('decodes control messages', () => {
|
|
121
|
+
expect(decodePeerMessage(new Uint8Array([0, 0, 0, 1, 0]))).toEqual({ type: 'choke' });
|
|
122
|
+
expect(decodePeerMessage(new Uint8Array([0, 0, 0, 1, 1]))).toEqual({ type: 'unchoke' });
|
|
123
|
+
expect(decodePeerMessage(new Uint8Array([0, 0, 0, 1, 2]))).toEqual({ type: 'interested' });
|
|
124
|
+
expect(decodePeerMessage(new Uint8Array([0, 0, 0, 1, 3]))).toEqual({
|
|
125
|
+
type: 'not-interested',
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test('decodes have', () => {
|
|
130
|
+
expect(decodePeerMessage(new Uint8Array([0, 0, 0, 5, 4, 0, 0, 0, 7]))).toEqual({
|
|
131
|
+
type: 'have',
|
|
132
|
+
pieceIndex: 7,
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test('decodes bitfield without copying payload bytes', () => {
|
|
137
|
+
const input = new Uint8Array([0, 0, 0, 2, 5, 0b10100000]);
|
|
138
|
+
const message = decodePeerMessage(input);
|
|
139
|
+
|
|
140
|
+
expect(message).toEqual({ type: 'bitfield', bitfield: new Uint8Array([0b10100000]) });
|
|
141
|
+
expect(message.type === 'bitfield' ? message.bitfield.buffer : undefined).toBe(
|
|
142
|
+
input.buffer,
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('decodes request', () => {
|
|
147
|
+
expect(
|
|
148
|
+
decodePeerMessage(
|
|
149
|
+
new Uint8Array([0, 0, 0, 13, 6, 0, 0, 0, 7, 0, 0, 64, 0, 0, 0, 64, 0]),
|
|
150
|
+
),
|
|
151
|
+
).toEqual({
|
|
152
|
+
type: 'request',
|
|
153
|
+
pieceIndex: 7,
|
|
154
|
+
offset: 16_384,
|
|
155
|
+
length: 16_384,
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('decodes piece', () => {
|
|
160
|
+
expect(
|
|
161
|
+
decodePeerMessage(new Uint8Array([0, 0, 0, 12, 7, 0, 0, 0, 7, 0, 0, 64, 0, 1, 2, 3])),
|
|
162
|
+
).toEqual({
|
|
163
|
+
type: 'piece',
|
|
164
|
+
pieceIndex: 7,
|
|
165
|
+
offset: 16_384,
|
|
166
|
+
block: new Uint8Array([1, 2, 3]),
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('decodes cancel', () => {
|
|
171
|
+
expect(
|
|
172
|
+
decodePeerMessage(
|
|
173
|
+
new Uint8Array([0, 0, 0, 13, 8, 0, 0, 0, 7, 0, 0, 64, 0, 0, 0, 64, 0]),
|
|
174
|
+
),
|
|
175
|
+
).toEqual({
|
|
176
|
+
type: 'cancel',
|
|
177
|
+
pieceIndex: 7,
|
|
178
|
+
offset: 16_384,
|
|
179
|
+
length: 16_384,
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('rejects missing length prefix', () => {
|
|
184
|
+
expectPeerMessageError(
|
|
185
|
+
() => decodePeerMessage(new Uint8Array([0, 0, 0])),
|
|
186
|
+
PeerMessageErrorCode.INVALID_LENGTH_PREFIX,
|
|
187
|
+
);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test('rejects mismatched message length', () => {
|
|
191
|
+
expectPeerMessageError(
|
|
192
|
+
() => decodePeerMessage(new Uint8Array([0, 0, 0, 2, 5])),
|
|
193
|
+
PeerMessageErrorCode.INVALID_MESSAGE_LENGTH,
|
|
194
|
+
);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test('rejects invalid fixed payload length', () => {
|
|
198
|
+
expectPeerMessageError(
|
|
199
|
+
() => decodePeerMessage(new Uint8Array([0, 0, 0, 2, 0, 1])),
|
|
200
|
+
PeerMessageErrorCode.INVALID_PAYLOAD_LENGTH,
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test('rejects piece payload shorter than index and offset', () => {
|
|
205
|
+
expectPeerMessageError(
|
|
206
|
+
() => decodePeerMessage(new Uint8Array([0, 0, 0, 8, 7, 0, 0, 0, 7, 0, 0, 64])),
|
|
207
|
+
PeerMessageErrorCode.INVALID_PAYLOAD_LENGTH,
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test('rejects unknown message id', () => {
|
|
212
|
+
expectPeerMessageError(
|
|
213
|
+
() => decodePeerMessage(new Uint8Array([0, 0, 0, 1, 99])),
|
|
214
|
+
PeerMessageErrorCode.UNKNOWN_MESSAGE_ID,
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test('roundtrips payload messages', () => {
|
|
219
|
+
const messages = [
|
|
220
|
+
{ type: 'have', pieceIndex: 7 },
|
|
221
|
+
{ type: 'bitfield', bitfield: new Uint8Array([0b10100000]) },
|
|
222
|
+
{ type: 'request', pieceIndex: 7, offset: 16_384, length: 16_384 },
|
|
223
|
+
{ type: 'piece', pieceIndex: 7, offset: 16_384, block: new Uint8Array([1, 2, 3]) },
|
|
224
|
+
{ type: 'cancel', pieceIndex: 7, offset: 16_384, length: 16_384 },
|
|
225
|
+
] as const;
|
|
226
|
+
|
|
227
|
+
for (const message of messages) {
|
|
228
|
+
expect(decodePeerMessage(encodePeerMessage(message))).toEqual(message);
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
export enum PeerMessageId {
|
|
2
|
+
Choke = 0,
|
|
3
|
+
Unchoke = 1,
|
|
4
|
+
Interested = 2,
|
|
5
|
+
NotInterested = 3,
|
|
6
|
+
Have = 4,
|
|
7
|
+
Bitfield = 5,
|
|
8
|
+
Request = 6,
|
|
9
|
+
Piece = 7,
|
|
10
|
+
Cancel = 8,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type KeepAliveMessage = {
|
|
14
|
+
type: 'keep-alive';
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type ChokeMessage = {
|
|
18
|
+
type: 'choke';
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type UnchokeMessage = {
|
|
22
|
+
type: 'unchoke';
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export type InterestedMessage = {
|
|
26
|
+
type: 'interested';
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type NotInterestedMessage = {
|
|
30
|
+
type: 'not-interested';
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type HaveMessage = {
|
|
34
|
+
type: 'have';
|
|
35
|
+
pieceIndex: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type BitfieldMessage = {
|
|
39
|
+
type: 'bitfield';
|
|
40
|
+
bitfield: Uint8Array;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export type RequestMessage = {
|
|
44
|
+
type: 'request';
|
|
45
|
+
pieceIndex: number;
|
|
46
|
+
offset: number;
|
|
47
|
+
length: number;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export type PieceMessage = {
|
|
51
|
+
type: 'piece';
|
|
52
|
+
pieceIndex: number;
|
|
53
|
+
offset: number;
|
|
54
|
+
block: Uint8Array;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type CancelMessage = {
|
|
58
|
+
type: 'cancel';
|
|
59
|
+
pieceIndex: number;
|
|
60
|
+
offset: number;
|
|
61
|
+
length: number;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export type PeerMessage =
|
|
65
|
+
| KeepAliveMessage
|
|
66
|
+
| ChokeMessage
|
|
67
|
+
| UnchokeMessage
|
|
68
|
+
| InterestedMessage
|
|
69
|
+
| NotInterestedMessage
|
|
70
|
+
| HaveMessage
|
|
71
|
+
| BitfieldMessage
|
|
72
|
+
| RequestMessage
|
|
73
|
+
| PieceMessage
|
|
74
|
+
| CancelMessage;
|
|
75
|
+
|
|
76
|
+
export { PeerMessageError, PeerMessageErrorCode } from './error';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ALPHABET, PEER_ID_PREFIX } from './consts';
|
|
2
|
+
|
|
3
|
+
const textEncoder = new TextEncoder('utf-8');
|
|
4
|
+
|
|
5
|
+
export const createPeerId = (): Uint8Array => {
|
|
6
|
+
const random = crypto.getRandomValues(new Uint8Array(12));
|
|
7
|
+
const suffix = [...random].map((byte) => ALPHABET[byte % ALPHABET.length]).join('');
|
|
8
|
+
return textEncoder.encode(`${PEER_ID_PREFIX}${suffix}`);
|
|
9
|
+
};
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
import { PeerSession, type PeerSessionConnectOptions } from '@peer/session';
|
|
2
|
+
import type { PeerInfo } from '@tracker/types';
|
|
3
|
+
import { PeerPoolError, PeerPoolErrorCode } from './pool.error';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_MAX_CONNECTING = 20;
|
|
6
|
+
const DEFAULT_CONNECT_TIMEOUT_MS = 3_000;
|
|
7
|
+
|
|
8
|
+
export type PeerConnectionSession = {
|
|
9
|
+
connect(
|
|
10
|
+
infoHash: Uint8Array,
|
|
11
|
+
peerId: Uint8Array,
|
|
12
|
+
options?: PeerSessionConnectOptions,
|
|
13
|
+
): Promise<void>;
|
|
14
|
+
close(): void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type PeerPoolOptions<TSession extends PeerConnectionSession = PeerSession> = {
|
|
18
|
+
infoHash: Uint8Array;
|
|
19
|
+
peerId: Uint8Array;
|
|
20
|
+
targetConnections: number;
|
|
21
|
+
totalPieces?: number;
|
|
22
|
+
minConnections?: number;
|
|
23
|
+
maxConnecting?: number;
|
|
24
|
+
timeoutMs?: number;
|
|
25
|
+
createSession?: (peer: PeerInfo) => TSession;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
type NormalizedPeerPoolOptions<TSession extends PeerConnectionSession> = {
|
|
29
|
+
infoHash: Uint8Array;
|
|
30
|
+
peerId: Uint8Array;
|
|
31
|
+
targetConnections: number;
|
|
32
|
+
totalPieces?: number;
|
|
33
|
+
minConnections: number;
|
|
34
|
+
maxConnecting: number;
|
|
35
|
+
timeoutMs: number;
|
|
36
|
+
createSession: (peer: PeerInfo) => TSession;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export class PeerPool<TSession extends PeerConnectionSession = PeerSession> {
|
|
40
|
+
private readonly connected: TSession[] = [];
|
|
41
|
+
private readonly inFlight = new Set<TSession>();
|
|
42
|
+
private readonly sessionListeners = new Set<(session: TSession) => void>();
|
|
43
|
+
private readonly errors: unknown[] = [];
|
|
44
|
+
|
|
45
|
+
private nextPeerIndex = 0;
|
|
46
|
+
private closed = false;
|
|
47
|
+
private readySettled = false;
|
|
48
|
+
private doneSettled = false;
|
|
49
|
+
|
|
50
|
+
public readonly ready: Promise<this>;
|
|
51
|
+
public readonly done: Promise<TSession[]>;
|
|
52
|
+
|
|
53
|
+
private resolveReady!: (pool: this) => void;
|
|
54
|
+
private rejectReady!: (error: unknown) => void;
|
|
55
|
+
private resolveDone!: (sessions: TSession[]) => void;
|
|
56
|
+
private rejectDone!: (error: unknown) => void;
|
|
57
|
+
|
|
58
|
+
public constructor(
|
|
59
|
+
private readonly peers: PeerInfo[],
|
|
60
|
+
private readonly options: NormalizedPeerPoolOptions<TSession>,
|
|
61
|
+
) {
|
|
62
|
+
this.ready = new Promise((resolve, reject) => {
|
|
63
|
+
this.resolveReady = resolve;
|
|
64
|
+
this.rejectReady = reject;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
this.done = new Promise((resolve, reject) => {
|
|
68
|
+
this.resolveDone = resolve;
|
|
69
|
+
this.rejectDone = reject;
|
|
70
|
+
});
|
|
71
|
+
void this.done.catch(() => undefined);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
public start(): void {
|
|
75
|
+
this.resolveReadyIfNeeded();
|
|
76
|
+
this.connectNext();
|
|
77
|
+
this.finishIfDone();
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
public get size(): number {
|
|
81
|
+
return this.connected.length;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
public get connecting(): number {
|
|
85
|
+
return this.inFlight.size;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
public get attempted(): number {
|
|
89
|
+
return this.nextPeerIndex;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
public get failed(): number {
|
|
93
|
+
return this.errors.length;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
public get totalPeers(): number {
|
|
97
|
+
return this.peers.length;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
public get targetConnections(): number {
|
|
101
|
+
return this.options.targetConnections;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
public get sessions(): TSession[] {
|
|
105
|
+
return [...this.connected];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
public onSession(callback: (session: TSession) => void): () => void {
|
|
109
|
+
this.sessionListeners.add(callback);
|
|
110
|
+
for (const session of this.connected) callback(session);
|
|
111
|
+
|
|
112
|
+
return () => {
|
|
113
|
+
this.sessionListeners.delete(callback);
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
public close(): void {
|
|
118
|
+
if (this.closed) return;
|
|
119
|
+
|
|
120
|
+
this.closed = true;
|
|
121
|
+
for (const session of this.connected) session.close();
|
|
122
|
+
for (const session of this.inFlight) session.close();
|
|
123
|
+
this.inFlight.clear();
|
|
124
|
+
this.resolveReadyIfNeeded();
|
|
125
|
+
this.resolveDoneIfNeeded();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private connectNext(): void {
|
|
129
|
+
if (this.closed) return;
|
|
130
|
+
|
|
131
|
+
while (
|
|
132
|
+
this.inFlight.size < this.options.maxConnecting &&
|
|
133
|
+
this.connected.length + this.inFlight.size < this.options.targetConnections &&
|
|
134
|
+
this.nextPeerIndex < this.peers.length
|
|
135
|
+
) {
|
|
136
|
+
const peer = this.peers[this.nextPeerIndex]!;
|
|
137
|
+
this.nextPeerIndex += 1;
|
|
138
|
+
|
|
139
|
+
const session = this.options.createSession(peer);
|
|
140
|
+
this.inFlight.add(session);
|
|
141
|
+
|
|
142
|
+
void session
|
|
143
|
+
.connect(this.options.infoHash, this.options.peerId, {
|
|
144
|
+
timeoutMs: this.options.timeoutMs,
|
|
145
|
+
totalPieces: this.options.totalPieces,
|
|
146
|
+
})
|
|
147
|
+
.then(() => this.handleConnected(session))
|
|
148
|
+
.catch((error) => this.handleFailed(session, error));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private handleConnected(session: TSession): void {
|
|
153
|
+
this.inFlight.delete(session);
|
|
154
|
+
|
|
155
|
+
if (this.closed) {
|
|
156
|
+
session.close();
|
|
157
|
+
this.finishIfDone();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
this.connected.push(session);
|
|
162
|
+
for (const listener of this.sessionListeners) listener(session);
|
|
163
|
+
|
|
164
|
+
this.resolveReadyIfNeeded();
|
|
165
|
+
this.connectNext();
|
|
166
|
+
this.finishIfDone();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private handleFailed(session: TSession, error: unknown): void {
|
|
170
|
+
this.inFlight.delete(session);
|
|
171
|
+
session.close();
|
|
172
|
+
this.errors.push(error);
|
|
173
|
+
|
|
174
|
+
this.connectNext();
|
|
175
|
+
this.finishIfDone();
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
private finishIfDone(): void {
|
|
179
|
+
if (this.doneSettled) return;
|
|
180
|
+
|
|
181
|
+
if (this.connected.length >= this.options.targetConnections) {
|
|
182
|
+
this.doneSettled = true;
|
|
183
|
+
this.resolveReadyIfNeeded();
|
|
184
|
+
this.resolveDone(this.sessions);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (this.inFlight.size > 0 || this.nextPeerIndex < this.peers.length) return;
|
|
189
|
+
|
|
190
|
+
this.doneSettled = true;
|
|
191
|
+
|
|
192
|
+
if (this.connected.length >= this.options.minConnections) {
|
|
193
|
+
this.resolveReadyIfNeeded();
|
|
194
|
+
this.resolveDone(this.sessions);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const error = new PeerPoolError(
|
|
199
|
+
PeerPoolErrorCode.NO_CONNECTABLE_PEERS,
|
|
200
|
+
'Not enough connectable peers',
|
|
201
|
+
this.errors,
|
|
202
|
+
);
|
|
203
|
+
for (const session of this.connected) session.close();
|
|
204
|
+
this.rejectReadyIfNeeded(error);
|
|
205
|
+
this.rejectDone(error);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private resolveReadyIfNeeded(): void {
|
|
209
|
+
if (this.readySettled || this.connected.length < this.options.minConnections) return;
|
|
210
|
+
|
|
211
|
+
this.readySettled = true;
|
|
212
|
+
this.resolveReady(this);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private rejectReadyIfNeeded(error: unknown): void {
|
|
216
|
+
if (this.readySettled) return;
|
|
217
|
+
|
|
218
|
+
this.readySettled = true;
|
|
219
|
+
this.rejectReady(error);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private resolveDoneIfNeeded(): void {
|
|
223
|
+
if (this.doneSettled) return;
|
|
224
|
+
|
|
225
|
+
this.doneSettled = true;
|
|
226
|
+
this.resolveDone(this.sessions);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export const openPeerPool = async <TSession extends PeerConnectionSession = PeerSession>(
|
|
231
|
+
peers: PeerInfo[],
|
|
232
|
+
options: PeerPoolOptions<TSession>,
|
|
233
|
+
): Promise<PeerPool<TSession>> => {
|
|
234
|
+
const normalizedOptions = normalizeOptions(options);
|
|
235
|
+
|
|
236
|
+
if (peers.length === 0 && normalizedOptions.minConnections > 0) {
|
|
237
|
+
throw new PeerPoolError(PeerPoolErrorCode.NO_PEERS, 'No peers to connect to');
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const pool = new PeerPool(peers, normalizedOptions);
|
|
241
|
+
pool.start();
|
|
242
|
+
return await pool.ready;
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
export const connectToPeers = async <TSession extends PeerConnectionSession = PeerSession>(
|
|
246
|
+
peers: PeerInfo[],
|
|
247
|
+
options: PeerPoolOptions<TSession>,
|
|
248
|
+
): Promise<TSession[]> => {
|
|
249
|
+
const pool = await openPeerPool(peers, options);
|
|
250
|
+
return await pool.done;
|
|
251
|
+
};
|
|
252
|
+
|
|
253
|
+
const normalizeOptions = <TSession extends PeerConnectionSession>(
|
|
254
|
+
options: PeerPoolOptions<TSession>,
|
|
255
|
+
): NormalizedPeerPoolOptions<TSession> => {
|
|
256
|
+
const targetConnections = assertPositiveInteger(options.targetConnections, 'targetConnections');
|
|
257
|
+
const minConnections = assertNonNegativeInteger(options.minConnections ?? 1, 'minConnections');
|
|
258
|
+
if (minConnections > targetConnections) {
|
|
259
|
+
throw new PeerPoolError(
|
|
260
|
+
PeerPoolErrorCode.INVALID_OPTION,
|
|
261
|
+
'minConnections cannot be greater than targetConnections',
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return {
|
|
266
|
+
infoHash: options.infoHash,
|
|
267
|
+
peerId: options.peerId,
|
|
268
|
+
targetConnections,
|
|
269
|
+
totalPieces: options.totalPieces,
|
|
270
|
+
minConnections,
|
|
271
|
+
maxConnecting: Math.max(1, options.maxConnecting ?? DEFAULT_MAX_CONNECTING),
|
|
272
|
+
timeoutMs: options.timeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
|
|
273
|
+
createSession:
|
|
274
|
+
options.createSession ??
|
|
275
|
+
((peer: PeerInfo) => new PeerSession(peer) as unknown as TSession),
|
|
276
|
+
};
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const assertPositiveInteger = (value: number, name: string): number => {
|
|
280
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
281
|
+
throw new PeerPoolError(
|
|
282
|
+
PeerPoolErrorCode.INVALID_OPTION,
|
|
283
|
+
`${name} must be a positive integer`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
return value;
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
const assertNonNegativeInteger = (value: number, name: string): number => {
|
|
291
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
292
|
+
throw new PeerPoolError(
|
|
293
|
+
PeerPoolErrorCode.INVALID_OPTION,
|
|
294
|
+
`${name} must be a non-negative integer`,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return value;
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
export { PeerPoolError, PeerPoolErrorCode } from './pool.error';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { BunTorrentError } from '@utils/errors';
|
|
2
|
+
|
|
3
|
+
export enum PeerPoolErrorCode {
|
|
4
|
+
INVALID_OPTION = 'PEER_POOL_INVALID_OPTION',
|
|
5
|
+
NO_CONNECTABLE_PEERS = 'PEER_POOL_NO_CONNECTABLE_PEERS',
|
|
6
|
+
NO_PEERS = 'PEER_POOL_NO_PEERS',
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export class PeerPoolError extends BunTorrentError {
|
|
10
|
+
constructor(
|
|
11
|
+
code: PeerPoolErrorCode,
|
|
12
|
+
message: string,
|
|
13
|
+
public readonly causes: unknown[] = [],
|
|
14
|
+
) {
|
|
15
|
+
super(message, code);
|
|
16
|
+
}
|
|
17
|
+
}
|