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.
Files changed (80) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1 -0
  3. package/package.json +49 -0
  4. package/src/app.ts +65 -0
  5. package/src/client.error.ts +12 -0
  6. package/src/client.test.ts +117 -0
  7. package/src/client.ts +185 -0
  8. package/src/index.test.ts +7 -0
  9. package/src/index.ts +76 -0
  10. package/src/peer/__tests__/peer-id.test.ts +24 -0
  11. package/src/peer/availability.test.ts +50 -0
  12. package/src/peer/availability.ts +88 -0
  13. package/src/peer/consts.ts +11 -0
  14. package/src/peer/handshake/handshake.error.ts +15 -0
  15. package/src/peer/handshake/handshake.test.ts +125 -0
  16. package/src/peer/handshake/index.ts +83 -0
  17. package/src/peer/index.ts +31 -0
  18. package/src/peer/messages/error.ts +16 -0
  19. package/src/peer/messages/helpers.ts +36 -0
  20. package/src/peer/messages/index.ts +174 -0
  21. package/src/peer/messages/messages.test.ts +231 -0
  22. package/src/peer/messages/types.ts +76 -0
  23. package/src/peer/peer-id.ts +9 -0
  24. package/src/peer/pool/index.ts +301 -0
  25. package/src/peer/pool/pool.error.ts +17 -0
  26. package/src/peer/pool/pool.test.ts +305 -0
  27. package/src/peer/session/index.ts +276 -0
  28. package/src/peer/session/session.error.ts +19 -0
  29. package/src/peer/session/session.test.ts +110 -0
  30. package/src/peer/types.ts +6 -0
  31. package/src/torrent/bencode/__tests__/decoder.test.ts +212 -0
  32. package/src/torrent/bencode/__tests__/encoder.test.ts +138 -0
  33. package/src/torrent/bencode/__tests__/encoder.unit.test.ts +24 -0
  34. package/src/torrent/bencode/__tests__/integration.test.ts +64 -0
  35. package/src/torrent/bencode/decoder.error.ts +36 -0
  36. package/src/torrent/bencode/decoder.ts +180 -0
  37. package/src/torrent/bencode/encoder.error.ts +14 -0
  38. package/src/torrent/bencode/encoder.ts +86 -0
  39. package/src/torrent/bencode/index.ts +9 -0
  40. package/src/torrent/bencode/types.ts +15 -0
  41. package/src/torrent/bencode/utils.ts +17 -0
  42. package/src/torrent/download/index.ts +9 -0
  43. package/src/torrent/download/manager.test.ts +393 -0
  44. package/src/torrent/download/manager.ts +376 -0
  45. package/src/torrent/file-selection.ts +51 -0
  46. package/src/torrent/index.ts +61 -0
  47. package/src/torrent/parser/helpers.ts +44 -0
  48. package/src/torrent/parser/index.ts +197 -0
  49. package/src/torrent/parser/info-hash.test.ts +39 -0
  50. package/src/torrent/parser/info-hash.ts +7 -0
  51. package/src/torrent/parser/parser.error.ts +21 -0
  52. package/src/torrent/parser/parser.test.ts +286 -0
  53. package/src/torrent/pieces/DefaultPlanner.ts +257 -0
  54. package/src/torrent/pieces/index.ts +20 -0
  55. package/src/torrent/pieces/planner.error.ts +18 -0
  56. package/src/torrent/pieces/planner.test.ts +303 -0
  57. package/src/torrent/pieces/planner.ts +16 -0
  58. package/src/torrent/pieces/types.ts +59 -0
  59. package/src/torrent/pieces/utils.ts +85 -0
  60. package/src/torrent/pieces/validation.test.ts +32 -0
  61. package/src/torrent/pieces/validation.ts +27 -0
  62. package/src/torrent/session/index.ts +195 -0
  63. package/src/torrent/session/session.test.ts +279 -0
  64. package/src/torrent/storage/index.ts +161 -0
  65. package/src/torrent/storage/storage.error.ts +18 -0
  66. package/src/torrent/storage/storage.test.ts +326 -0
  67. package/src/torrent/storage/types.ts +13 -0
  68. package/src/torrent/types.ts +16 -0
  69. package/src/tracker/http.test.ts +66 -0
  70. package/src/tracker/http.ts +137 -0
  71. package/src/tracker/index.ts +93 -0
  72. package/src/tracker/tracker.error.ts +26 -0
  73. package/src/tracker/types.ts +17 -0
  74. package/src/tracker/udp.test.ts +155 -0
  75. package/src/tracker/udp.ts +234 -0
  76. package/src/utils/__tests__/sha1.test.ts +16 -0
  77. package/src/utils/buffers.ts +28 -0
  78. package/src/utils/errors.ts +9 -0
  79. package/src/utils/formats.ts +8 -0
  80. package/src/utils/sha1.ts +4 -0
@@ -0,0 +1,376 @@
1
+ import type { PeerMessage } from '@peer/messages';
2
+ import type {
3
+ PieceAvailability,
4
+ PieceBlockRequest,
5
+ PieceCompletion,
6
+ PiecePlanner,
7
+ } from '../pieces';
8
+ import { createPiecePlanner } from '../pieces';
9
+ import type { TorrentMetadata } from '../types';
10
+ import { writeValidatedPiece, type WritePieceOptions } from '../storage';
11
+ import { formatBytes } from '@utils/formats';
12
+ import type { TorrentFileSelection } from '../file-selection';
13
+ import { getSelectedPieceIndexes } from '../file-selection';
14
+
15
+ export type DownloadPeerSession = {
16
+ readonly choked: boolean;
17
+ readonly peerAvailability: PieceAvailability;
18
+ onClose(callback: () => void): () => void;
19
+ onMessage(callback: (message: PeerMessage) => void): () => void;
20
+ sendMessage(message: PeerMessage): void;
21
+ };
22
+
23
+ export type DownloadPeerPool<TPeer extends DownloadPeerSession = DownloadPeerSession> = {
24
+ readonly done?: Promise<readonly TPeer[]>;
25
+ onSession(callback: (session: TPeer) => void): () => void;
26
+ };
27
+
28
+ export type DownloadManagerOptions<TPeer extends DownloadPeerSession = DownloadPeerSession> = {
29
+ metadata: TorrentMetadata;
30
+ outputDirectory: string;
31
+ peerPool: DownloadPeerPool<TPeer>;
32
+ files?: TorrentFileSelection;
33
+ maxInFlightRequestsPerPeer?: number;
34
+ progressEvents?: DownloadProgressEventMode;
35
+ requestTimeoutMs?: number;
36
+ speedSampleIntervalMs?: number;
37
+ planner?: PiecePlanner;
38
+ writeValidatedPiece?: typeof writeValidatedPiece;
39
+ };
40
+
41
+ export type DownloadProgressEventMode = 'piece' | 'block';
42
+
43
+ export type DownloadProgress = {
44
+ totalBytes: number;
45
+ receivedBytes: number;
46
+ downloadedBytes: number;
47
+ totalPieces: number;
48
+ completedPieces: number;
49
+ percent: number;
50
+ speedBytesPerSecond: number;
51
+ speed: string;
52
+ };
53
+
54
+ export type DownloadProgressListener = (progress: DownloadProgress) => void;
55
+
56
+ type PeerDownloadState<TPeer extends DownloadPeerSession> = {
57
+ peer: TPeer;
58
+ pending: PendingPeerRequest[];
59
+ interestedSent: boolean;
60
+ offClose: () => void;
61
+ offMessage: () => void;
62
+ };
63
+
64
+ type PendingPeerRequest = {
65
+ request: PieceBlockRequest;
66
+ timeout: ReturnType<typeof setTimeout>;
67
+ };
68
+
69
+ const DEFAULT_MAX_IN_FLIGHT_REQUESTS_PER_PEER = 20;
70
+ const DEFAULT_PROGRESS_EVENTS: DownloadProgressEventMode = 'piece';
71
+ const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
72
+ const DEFAULT_SPEED_SAMPLE_INTERVAL_MS = 500;
73
+
74
+ export class DownloadManager<TPeer extends DownloadPeerSession = DownloadPeerSession> {
75
+ public readonly done: Promise<void>;
76
+
77
+ private readonly planner: PiecePlanner;
78
+ private readonly maxInFlightRequestsPerPeer: number;
79
+ private readonly progressEvents: DownloadProgressEventMode;
80
+ private readonly requestTimeoutMs: number;
81
+ private readonly speedSampleIntervalMs: number;
82
+ private readonly peerStates = new Map<TPeer, PeerDownloadState<TPeer>>();
83
+ private readonly progressListeners = new Set<DownloadProgressListener>();
84
+ private readonly writeValidated: typeof writeValidatedPiece;
85
+ private offSession: (() => void) | null = null;
86
+ private lastProgressSample: { receivedBytes: number; timestampMs: number } | null = null;
87
+ private currentSpeedBytesPerSecond = 0;
88
+ private closed = false;
89
+ private resolveDone!: () => void;
90
+ private rejectDone!: (error: unknown) => void;
91
+
92
+ public constructor(private readonly options: DownloadManagerOptions<TPeer>) {
93
+ this.planner =
94
+ options.planner ??
95
+ createPiecePlanner(options.metadata, {
96
+ pieceIndexes: getSelectedPieceIndexes(options.metadata, options.files),
97
+ });
98
+ this.maxInFlightRequestsPerPeer =
99
+ options.maxInFlightRequestsPerPeer ?? DEFAULT_MAX_IN_FLIGHT_REQUESTS_PER_PEER;
100
+ this.progressEvents = options.progressEvents ?? DEFAULT_PROGRESS_EVENTS;
101
+ this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
102
+ this.speedSampleIntervalMs =
103
+ options.speedSampleIntervalMs ?? DEFAULT_SPEED_SAMPLE_INTERVAL_MS;
104
+ this.writeValidated = options.writeValidatedPiece ?? writeValidatedPiece;
105
+ this.done = new Promise((resolve, reject) => {
106
+ this.resolveDone = resolve;
107
+ this.rejectDone = reject;
108
+ });
109
+ }
110
+
111
+ public get progress(): DownloadProgress {
112
+ let receivedBytes = 0;
113
+ let downloadedBytes = 0;
114
+ let totalBytes = 0;
115
+
116
+ for (const pieceIndex of this.planner.pieceIndexes) {
117
+ const piece = this.planner.getProgress(pieceIndex);
118
+ totalBytes += piece.length;
119
+ receivedBytes += piece.receivedBytes;
120
+ if (piece.status === 'complete') downloadedBytes += piece.length;
121
+ }
122
+
123
+ return {
124
+ totalBytes,
125
+ receivedBytes,
126
+ downloadedBytes,
127
+ totalPieces: this.planner.totalPieces,
128
+ completedPieces: this.planner.completedPieces,
129
+ percent: totalBytes === 0 ? 1 : downloadedBytes / totalBytes,
130
+ speedBytesPerSecond: this.currentSpeedBytesPerSecond,
131
+ speed: `${formatBytes(this.currentSpeedBytesPerSecond)}ps`,
132
+ };
133
+ }
134
+
135
+ public start(): void {
136
+ if (this.closed || this.offSession) return;
137
+
138
+ this.offSession = this.options.peerPool.onSession((session) => this.attachPeer(session));
139
+ void this.options.peerPool.done?.then((sessions) => {
140
+ if (sessions.length === 0) this.resolveDone();
141
+ });
142
+ this.resolveIfComplete();
143
+ }
144
+
145
+ public onProgress(listener: DownloadProgressListener): () => void {
146
+ this.progressListeners.add(listener);
147
+
148
+ return () => {
149
+ this.progressListeners.delete(listener);
150
+ };
151
+ }
152
+
153
+ public close(): void {
154
+ if (this.closed) return;
155
+
156
+ this.closed = true;
157
+ this.offSession?.();
158
+ this.offSession = null;
159
+
160
+ for (const state of this.peerStates.values()) {
161
+ this.detachPeer(state);
162
+ }
163
+ this.peerStates.clear();
164
+ this.resolveDone();
165
+ }
166
+
167
+ private attachPeer(peer: TPeer): void {
168
+ if (this.closed || this.peerStates.has(peer)) return;
169
+
170
+ const state: PeerDownloadState<TPeer> = {
171
+ peer,
172
+ pending: [],
173
+ interestedSent: false,
174
+ offClose: peer.onClose(() => this.handlePeerClosed(peer)),
175
+ offMessage: peer.onMessage((message) => this.handlePeerMessage(peer, message)),
176
+ };
177
+
178
+ this.peerStates.set(peer, state);
179
+ this.pumpPeer(state);
180
+ }
181
+
182
+ private detachPeer(state: PeerDownloadState<TPeer>): void {
183
+ state.offClose();
184
+ state.offMessage();
185
+ this.clearPendingTimeouts(state.pending);
186
+ this.planner.resetPeerRequests(state.pending.map((pending) => pending.request));
187
+ state.pending = [];
188
+ }
189
+
190
+ private handlePeerClosed(peer: TPeer): void {
191
+ const state = this.peerStates.get(peer);
192
+ if (!state) return;
193
+
194
+ this.detachPeer(state);
195
+ this.peerStates.delete(peer);
196
+ }
197
+
198
+ private handlePeerMessage(peer: TPeer, message: PeerMessage): void {
199
+ const state = this.peerStates.get(peer);
200
+ if (!state || this.closed) return;
201
+
202
+ if (message.type === 'unchoke' || message.type === 'have' || message.type === 'bitfield') {
203
+ this.pumpPeer(state);
204
+ return;
205
+ }
206
+
207
+ if (message.type === 'piece') {
208
+ this.handlePiece(state, message).catch((error) => this.rejectDone(error));
209
+ }
210
+ }
211
+
212
+ private async handlePiece(
213
+ state: PeerDownloadState<TPeer>,
214
+ message: Extract<PeerMessage, { type: 'piece' }>,
215
+ ): Promise<void> {
216
+ const pendingIndex = state.pending.findIndex(
217
+ ({ request }) =>
218
+ request.pieceIndex === message.pieceIndex &&
219
+ request.offset === message.offset &&
220
+ request.length === message.block.byteLength,
221
+ );
222
+ if (pendingIndex === -1) return;
223
+
224
+ const [pending] = state.pending.splice(pendingIndex, 1);
225
+ if (pending) clearTimeout(pending.timeout);
226
+
227
+ const completion = this.planner.receiveBlock(message);
228
+
229
+ if (completion) {
230
+ await this.handleCompletion(completion);
231
+ this.emitProgress();
232
+ } else if (this.progressEvents === 'block') {
233
+ this.emitProgress();
234
+ }
235
+
236
+ this.pumpPeer(state);
237
+ this.resolveIfComplete();
238
+ }
239
+
240
+ private async handleCompletion(completion: PieceCompletion): Promise<void> {
241
+ const result = await this.writeValidated(this.options.metadata, completion, {
242
+ outputDirectory: this.options.outputDirectory,
243
+ files: this.options.files,
244
+ } satisfies WritePieceOptions);
245
+
246
+ if (!result.valid) {
247
+ this.resetPieceForRetry(completion.pieceIndex);
248
+ }
249
+ }
250
+
251
+ private pumpPeer(state: PeerDownloadState<TPeer>): void {
252
+ if (this.planner.complete) return;
253
+
254
+ this.sendInterestedIfUseful(state);
255
+ if (state.peer.choked) return;
256
+
257
+ while (state.pending.length < this.maxInFlightRequestsPerPeer) {
258
+ const request = this.planner.nextRequest(state.peer.peerAvailability);
259
+ if (!request) break;
260
+
261
+ this.planner.markPending(request);
262
+ const timeout = setTimeout(
263
+ () => this.handleRequestTimeout(state, request),
264
+ this.requestTimeoutMs,
265
+ );
266
+ unrefTimer(timeout);
267
+ state.pending.push({
268
+ request,
269
+ timeout,
270
+ });
271
+ state.peer.sendMessage(request);
272
+ }
273
+ }
274
+
275
+ private handleRequestTimeout(
276
+ state: PeerDownloadState<TPeer>,
277
+ request: PieceBlockRequest,
278
+ ): void {
279
+ if (this.closed || this.planner.complete) return;
280
+
281
+ const pendingIndex = state.pending.findIndex((pending) =>
282
+ isSameRequest(pending.request, request),
283
+ );
284
+ if (pendingIndex === -1) return;
285
+
286
+ const [pending] = state.pending.splice(pendingIndex, 1);
287
+ if (pending) clearTimeout(pending.timeout);
288
+
289
+ this.planner.resetPending(request);
290
+ this.pumpPeers(state);
291
+ }
292
+
293
+ private sendInterestedIfUseful(state: PeerDownloadState<TPeer>): void {
294
+ if (state.interestedSent) return;
295
+ if (!this.planner.nextRequest(state.peer.peerAvailability)) return;
296
+
297
+ state.peer.sendMessage({ type: 'interested' });
298
+ state.interestedSent = true;
299
+ }
300
+
301
+ private resetPieceForRetry(pieceIndex: number): void {
302
+ this.planner.resetPiece(pieceIndex);
303
+
304
+ for (const state of this.peerStates.values()) {
305
+ const kept: PendingPeerRequest[] = [];
306
+ for (const pending of state.pending) {
307
+ if (pending.request.pieceIndex === pieceIndex) {
308
+ clearTimeout(pending.timeout);
309
+ } else {
310
+ kept.push(pending);
311
+ }
312
+ }
313
+ state.pending = kept;
314
+ }
315
+ }
316
+
317
+ private pumpPeers(last?: PeerDownloadState<TPeer>): void {
318
+ for (const state of this.peerStates.values()) {
319
+ if (state !== last) this.pumpPeer(state);
320
+ }
321
+ if (last) this.pumpPeer(last);
322
+ }
323
+
324
+ private clearPendingTimeouts(pending: PendingPeerRequest[]): void {
325
+ for (const request of pending) clearTimeout(request.timeout);
326
+ }
327
+
328
+ private emitProgress(): void {
329
+ this.updateSpeed();
330
+ const progress = this.progress;
331
+ for (const listener of this.progressListeners) listener(progress);
332
+ }
333
+
334
+ private updateSpeed(): void {
335
+ const now = Date.now();
336
+ const receivedBytes = this.getReceivedBytes();
337
+ const last = this.lastProgressSample;
338
+
339
+ if (last) {
340
+ const elapsedMs = now - last.timestampMs;
341
+ if (elapsedMs < this.speedSampleIntervalMs) return;
342
+
343
+ const elapsedSeconds = elapsedMs / 1_000;
344
+ const receivedDelta = receivedBytes - last.receivedBytes;
345
+ this.currentSpeedBytesPerSecond =
346
+ elapsedSeconds > 0 ? Math.max(0, receivedDelta / elapsedSeconds) : 0;
347
+ }
348
+
349
+ this.lastProgressSample = { receivedBytes, timestampMs: now };
350
+ }
351
+
352
+ private getReceivedBytes(): number {
353
+ let receivedBytes = 0;
354
+
355
+ for (const pieceIndex of this.planner.pieceIndexes) {
356
+ receivedBytes += this.planner.getProgress(pieceIndex).receivedBytes;
357
+ }
358
+
359
+ return receivedBytes;
360
+ }
361
+
362
+ private resolveIfComplete(): void {
363
+ if (this.planner.complete) {
364
+ this.resolveDone();
365
+ }
366
+ }
367
+ }
368
+
369
+ const isSameRequest = (a: PieceBlockRequest, b: PieceBlockRequest): boolean =>
370
+ a.pieceIndex === b.pieceIndex && a.offset === b.offset && a.length === b.length;
371
+
372
+ const unrefTimer = (timer: ReturnType<typeof setTimeout>): void => {
373
+ if (typeof timer === 'object' && timer && 'unref' in timer) {
374
+ (timer as { unref: () => void }).unref();
375
+ }
376
+ };
@@ -0,0 +1,51 @@
1
+ import type { TorrentMetadata } from './types';
2
+
3
+ export type TorrentFileSelection = readonly (string | readonly string[])[] | null;
4
+
5
+ export const getTorrentFilePathKey = (path: readonly string[]): string => path.join('/');
6
+
7
+ export const normalizeTorrentFileSelection = (
8
+ files: TorrentFileSelection | undefined,
9
+ ): Set<string> | undefined => {
10
+ if (files === undefined || files === null) return undefined;
11
+
12
+ return new Set(
13
+ files.map((file) => (typeof file === 'string' ? file : getTorrentFilePathKey(file))),
14
+ );
15
+ };
16
+
17
+ export const getSelectedPieceIndexes = (
18
+ metadata: TorrentMetadata,
19
+ files: TorrentFileSelection | undefined,
20
+ ): number[] | undefined => {
21
+ const selectedFiles = normalizeTorrentFileSelection(files);
22
+ if (!selectedFiles) return undefined;
23
+
24
+ const selectedPieces = new Set<number>();
25
+
26
+ for (const file of metadata.files) {
27
+ if (!selectedFiles.has(getTorrentFilePathKey(file.path))) continue;
28
+
29
+ const fileStart = file.offset;
30
+ const fileEnd = file.offset + file.length;
31
+ const firstPiece = Math.floor(fileStart / metadata.pieceLength);
32
+ const lastPiece = Math.floor(Math.max(fileStart, fileEnd - 1) / metadata.pieceLength);
33
+
34
+ for (let pieceIndex = firstPiece; pieceIndex <= lastPiece; pieceIndex += 1) {
35
+ selectedPieces.add(pieceIndex);
36
+ }
37
+ }
38
+
39
+ return [...selectedPieces].sort((a, b) => a - b);
40
+ };
41
+
42
+ export const getUnknownSelectedFiles = (
43
+ metadata: TorrentMetadata,
44
+ files: TorrentFileSelection | undefined,
45
+ ): string[] => {
46
+ const selectedFiles = normalizeTorrentFileSelection(files);
47
+ if (!selectedFiles) return [];
48
+
49
+ const availableFiles = new Set(metadata.files.map((file) => getTorrentFilePathKey(file.path)));
50
+ return [...selectedFiles].filter((file) => !availableFiles.has(file));
51
+ };
@@ -0,0 +1,61 @@
1
+ export { parseTorrent } from './parser/index';
2
+ export { computeInfoHash } from './parser/info-hash';
3
+ export { Torrent, TorrentState } from './session/index';
4
+ export {
5
+ getSelectedPieceIndexes,
6
+ getTorrentFilePathKey,
7
+ getUnknownSelectedFiles,
8
+ normalizeTorrentFileSelection,
9
+ } from './file-selection';
10
+ export { DownloadManager } from './download';
11
+ export {
12
+ DEFAULT_BLOCK_LENGTH,
13
+ createPiecePlanner,
14
+ getPieceLength,
15
+ isValidBlockForRequest,
16
+ PiecePlannerError,
17
+ PiecePlannerErrorCode,
18
+ splitPieceIntoRequests,
19
+ validatePiece,
20
+ } from './pieces';
21
+ export {
22
+ planPieceWrites,
23
+ TorrentStorageError,
24
+ TorrentStorageErrorCode,
25
+ writePiece,
26
+ writeValidatedPiece,
27
+ } from './storage';
28
+
29
+ export { decodeBencode, encodeBencode, toBValue } from './bencode';
30
+ export {
31
+ BencodeDecodeError,
32
+ BencodeDecodeErrorCode,
33
+ BencodeEncodeError,
34
+ BencodeEncodeErrorCode,
35
+ } from './bencode';
36
+ export { TorrentParseError, TorrentParseErrorCode } from './parser/parser.error';
37
+
38
+ export type { BBytes, BDict, BInteger, BList, BValue, BencodeInput } from './bencode';
39
+ export type {
40
+ PieceAvailability,
41
+ PieceBlock,
42
+ PieceBlockRequest,
43
+ PieceCompletion,
44
+ PiecePlanner,
45
+ PiecePlannerOptions,
46
+ PieceProgress,
47
+ PieceStatus,
48
+ PieceValidationResult,
49
+ } from './pieces';
50
+ export type {
51
+ DownloadManagerOptions,
52
+ DownloadPeerPool,
53
+ DownloadPeerSession,
54
+ DownloadProgress,
55
+ DownloadProgressEventMode,
56
+ DownloadProgressListener,
57
+ } from './download';
58
+ export type { TorrentFileSelection } from './file-selection';
59
+ export type { FileWrite, WritePieceOptions } from './storage';
60
+ export type { TorrentFiles, TorrentStateChange, TorrentStats } from './session/index';
61
+ export type { TorrentMetadata } from './types';
@@ -0,0 +1,44 @@
1
+ import type { BDict, BValue } from '../bencode/types';
2
+
3
+ import { TorrentParseError, TorrentParseErrorCode } from './parser.error';
4
+ const textDecoder = new TextDecoder('utf-8', { fatal: true });
5
+
6
+ export const expectField = (dict: BDict, field: string): BValue => {
7
+ const value = dict.get(field);
8
+
9
+ if (value === undefined) {
10
+ return fail(TorrentParseErrorCode.FIELD_MISSING, `Missing field: ${field}`, field);
11
+ }
12
+
13
+ return value;
14
+ };
15
+
16
+ export const expectDict = (value: BValue, field: string): BDict => {
17
+ if (value instanceof Map) return value;
18
+
19
+ return fail(TorrentParseErrorCode.FIELD_INVALID, `Expected dictionary: ${field}`, field);
20
+ };
21
+
22
+ export const readBytes = (dict: BDict, field: string): Uint8Array => {
23
+ const value = expectField(dict, field);
24
+
25
+ if (value instanceof Uint8Array) return value;
26
+
27
+ return fail(TorrentParseErrorCode.FIELD_INVALID, `Expected bytes: ${field}`, field);
28
+ };
29
+
30
+ export const readInteger = (dict: BDict, field: string): number => {
31
+ const value = expectField(dict, field);
32
+
33
+ if (typeof value === 'number') return value;
34
+
35
+ return fail(TorrentParseErrorCode.FIELD_INVALID, `Expected integer: ${field}`, field);
36
+ };
37
+
38
+ export const readText = (dict: BDict, field: string): string => {
39
+ return textDecoder.decode(readBytes(dict, field));
40
+ };
41
+
42
+ export const fail = (code: TorrentParseErrorCode, message: string, field?: string): never => {
43
+ throw new TorrentParseError(code, message, field);
44
+ };