bytifi 0.2.5 → 0.2.7

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/lib/decrypt.js CHANGED
@@ -9,15 +9,24 @@ import {
9
9
  usesChunkCompression,
10
10
  } from './crypto.js'
11
11
  import { fromBase64Url } from './base64url.js'
12
- import { BytifiApiError, fetchPublicBinaryWithRetry, publicFetchWithRetry } from './upload.js'
12
+ import { fetchPublicBinaryWithRetry, publicFetchWithRetry } from './api.js'
13
+ import { normalizeBaseUrl } from './url.js'
13
14
 
14
- const DEFAULT_BASE_URL = 'https://bytifi.com'
15
+ const DEFAULT_DECRYPT_CONCURRENCY = 2
15
16
 
16
- function normalizeBaseUrl(baseUrl) {
17
- return String(baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '')
17
+ function decryptProgress(overrides) {
18
+ return { stage: 'decrypting', percent: 0, ...overrides }
18
19
  }
19
20
 
20
- export function parseDecryptInput(input, { encryptionToken = '', baseUrl = DEFAULT_BASE_URL } = {}) {
21
+ function resolveDecryptConcurrency(requested) {
22
+ const value = Number(requested)
23
+ if (Number.isFinite(value) && value > 0) {
24
+ return Math.min(8, Math.max(1, Math.floor(value)))
25
+ }
26
+ return DEFAULT_DECRYPT_CONCURRENCY
27
+ }
28
+
29
+ export function parseDecryptInput(input, { encryptionToken = '', baseUrl = undefined } = {}) {
21
30
  const trimmed = String(input || '').trim()
22
31
  if (!trimmed) {
23
32
  throw new Error('Missing share URL or link token.')
@@ -67,7 +76,7 @@ export function parseDecryptInput(input, { encryptionToken = '', baseUrl = DEFAU
67
76
  }
68
77
  }
69
78
 
70
- export function parseShareReference(input, { encryptionToken = '', baseUrl = DEFAULT_BASE_URL } = {}) {
79
+ export function parseShareReference(input, { encryptionToken = '', baseUrl = undefined } = {}) {
71
80
  const trimmed = String(input || '').trim()
72
81
  if (!trimmed) {
73
82
  return {
@@ -127,16 +136,16 @@ async function resolveEncryptionMeta({
127
136
  metaPath = '',
128
137
  inlineMeta = null,
129
138
  linkToken = '',
130
- baseUrl = DEFAULT_BASE_URL,
139
+ baseUrl,
131
140
  signal,
132
141
  onStatus,
133
142
  }) {
134
143
  if (inlineMeta) {
135
- return { meta: inlineMeta }
144
+ return { meta: inlineMeta, skipApiMetaFetch: true }
136
145
  }
137
146
 
138
147
  if (metaPath) {
139
- return { meta: await readMetaFile(metaPath) }
148
+ return { meta: await readMetaFile(metaPath), skipApiMetaFetch: true }
140
149
  }
141
150
 
142
151
  if (!linkToken) {
@@ -169,6 +178,10 @@ function sanitizeOutputName(filename) {
169
178
  return base || 'download'
170
179
  }
171
180
 
181
+ async function cleanupPartialOutput(outputPath) {
182
+ await fs.unlink(outputPath).catch(() => {})
183
+ }
184
+
172
185
  async function fetchLinkInfo(baseUrl, linkToken, signal, onStatus) {
173
186
  return publicFetchWithRetry(baseUrl, `/api/link/${encodeURIComponent(linkToken)}`, { signal, onStatus })
174
187
  }
@@ -181,6 +194,14 @@ async function fetchEncryptedPart(baseUrl, linkToken, partNumber, signal, onStat
181
194
  )
182
195
  }
183
196
 
197
+ function splitDownloadUrl(encryptedFileUrl, fallbackBaseUrl) {
198
+ const url = new URL(encryptedFileUrl, `${normalizeBaseUrl(fallbackBaseUrl)}/`)
199
+ return {
200
+ baseUrl: `${url.protocol}//${url.host}`,
201
+ path: `${url.pathname}${url.search}`,
202
+ }
203
+ }
204
+
184
205
  async function decryptFromParts({
185
206
  baseUrl,
186
207
  linkToken,
@@ -191,38 +212,48 @@ async function decryptFromParts({
191
212
  onProgress,
192
213
  onStatus,
193
214
  signal,
215
+ concurrency = DEFAULT_DECRYPT_CONCURRENCY,
194
216
  }) {
195
217
  const { chunks } = buildEncryptedChunkPlan(meta)
196
218
  const fileHandle = await fs.open(outputPath, 'w')
197
219
  let decryptedBytes = 0
220
+ const workerCount = Math.min(resolveDecryptConcurrency(concurrency), chunks.length)
221
+ let nextIndex = 0
222
+ let completedParts = 0
223
+ let inFlightParts = 0
198
224
 
199
- try {
200
- for (let index = 0; index < chunks.length; index += 1) {
201
- if (signal?.aborted) {
202
- throw new Error('Decrypt aborted.')
203
- }
225
+ function partPercent() {
226
+ const weighted = completedParts + inFlightParts * 0.5
227
+ return Math.min(100, Math.round((weighted / chunks.length) * 100))
228
+ }
204
229
 
205
- const chunk = chunks[index]
206
- const partNumber = chunk.chunkIndex + 1
230
+ async function processPart(index) {
231
+ if (signal?.aborted) {
232
+ throw new Error('Decrypt aborted.')
233
+ }
234
+
235
+ const chunk = chunks[index]
236
+ const partNumber = chunk.chunkIndex + 1
237
+ inFlightParts += 1
207
238
 
208
- onProgress?.({
239
+ try {
240
+ onProgress?.(decryptProgress({
209
241
  stage: 'downloading',
210
242
  part: partNumber,
211
243
  totalParts: chunks.length,
212
- downloadedBytes: decryptedBytes,
213
244
  totalBytes: meta.originalSize,
214
- percent: Math.round((decryptedBytes / meta.originalSize) * 100),
215
- })
245
+ percent: partPercent(),
246
+ }))
216
247
 
217
248
  const encryptedPart = await fetchEncryptedPart(baseUrl, linkToken, partNumber, signal, onStatus)
218
249
 
219
- onProgress?.({
250
+ onProgress?.(decryptProgress({
220
251
  stage: 'decrypting',
221
252
  part: partNumber,
222
253
  totalParts: chunks.length,
223
254
  partBytes: encryptedPart.length,
224
- percent: Math.round((decryptedBytes / meta.originalSize) * 100),
225
- })
255
+ percent: partPercent(),
256
+ }))
226
257
 
227
258
  const plainPart = await decryptPlainChunkFromEncrypted(
228
259
  encryptedPart,
@@ -232,19 +263,45 @@ async function decryptFromParts({
232
263
  meta,
233
264
  )
234
265
 
235
- await fileHandle.write(plainPart)
236
- decryptedBytes += plainPart.length
266
+ return { index, partNumber, plainPart }
267
+ } finally {
268
+ inFlightParts -= 1
269
+ }
270
+ }
237
271
 
238
- onProgress?.({
239
- stage: 'writing',
240
- part: partNumber,
241
- totalParts: chunks.length,
242
- downloadedBytes: decryptedBytes,
243
- totalBytes: meta.originalSize,
244
- percent: Math.round((decryptedBytes / meta.originalSize) * 100),
245
- detail: `${partNumber}/${chunks.length} parts`,
246
- })
272
+ try {
273
+ const results = new Array(chunks.length)
274
+ const workers = Array.from({ length: workerCount }, async () => {
275
+ while (true) {
276
+ const index = nextIndex
277
+ nextIndex += 1
278
+ if (index >= chunks.length) return
279
+
280
+ const result = await processPart(index)
281
+ results[result.index] = result.plainPart
282
+ completedParts += 1
283
+ decryptedBytes += result.plainPart.length
284
+
285
+ onProgress?.(decryptProgress({
286
+ stage: 'writing',
287
+ part: result.partNumber,
288
+ totalParts: chunks.length,
289
+ downloadedBytes: decryptedBytes,
290
+ totalBytes: meta.originalSize,
291
+ percent: Math.round((decryptedBytes / meta.originalSize) * 100),
292
+ detail: `${completedParts}/${chunks.length} parts`,
293
+ }))
294
+ }
295
+ })
296
+
297
+ await Promise.all(workers)
298
+
299
+ for (const plainPart of results) {
300
+ await fileHandle.write(plainPart)
247
301
  }
302
+ } catch (error) {
303
+ await cleanupPartialOutput(outputPath)
304
+ throw error
248
305
  } finally {
249
306
  await fileHandle.close()
250
307
  }
@@ -259,17 +316,11 @@ async function decryptFromSingleFile({
259
316
  noncePrefix,
260
317
  outputPath,
261
318
  onProgress,
319
+ onStatus,
262
320
  signal,
321
+ baseUrl,
263
322
  }) {
264
- const response = await fetch(encryptedFileUrl, { signal })
265
-
266
- if (!response.ok) {
267
- const text = await response.text()
268
- throw new BytifiApiError(text || 'Failed to download encrypted file.', {
269
- status: response.status,
270
- body: text,
271
- })
272
- }
323
+ const { baseUrl: downloadBaseUrl, path: downloadPath } = splitDownloadUrl(encryptedFileUrl, baseUrl)
273
324
 
274
325
  if (usesChunkCompression(meta)) {
275
326
  if (meta.chunkCount !== 1) {
@@ -278,7 +329,7 @@ async function decryptFromSingleFile({
278
329
  )
279
330
  }
280
331
 
281
- const encryptedBuffer = Buffer.from(await response.arrayBuffer())
332
+ const encryptedBuffer = await fetchPublicBinaryWithRetry(downloadBaseUrl, downloadPath, { signal, onStatus })
282
333
  const plainPart = await decryptPlainChunkFromEncrypted(
283
334
  encryptedBuffer,
284
335
  tokenBytes,
@@ -286,93 +337,27 @@ async function decryptFromSingleFile({
286
337
  0,
287
338
  meta,
288
339
  )
289
- await fs.writeFile(outputPath, plainPart)
290
- onProgress?.(100)
291
- return
292
- }
293
340
 
294
- const { chunks, totalEncryptedSize } = buildEncryptedChunkPlan(meta)
341
+ try {
342
+ await fs.writeFile(outputPath, plainPart)
343
+ } catch (error) {
344
+ await cleanupPartialOutput(outputPath)
345
+ throw error
346
+ }
295
347
 
296
- if (!response.body) {
297
- const encryptedBuffer = Buffer.from(await response.arrayBuffer())
298
- await writeDecryptedBuffer(encryptedBuffer, chunks, meta, tokenBytes, noncePrefix, outputPath, onProgress, signal)
348
+ onProgress?.({ stage: 'complete', percent: 100 })
299
349
  return
300
350
  }
301
351
 
302
- const reader = response.body.getReader()
303
- const fileHandle = await fs.open(outputPath, 'w')
304
- const pending = []
305
- let pendingLength = 0
306
- let downloadedBytes = 0
307
- let nextChunkIndex = 0
308
- let decryptedBytes = 0
309
-
310
- const takeBytes = (length) => {
311
- while (pendingLength < length) {
312
- return null
313
- }
314
-
315
- const out = Buffer.alloc(length)
316
- let offset = 0
317
-
318
- while (offset < length) {
319
- const head = pending[0]
320
- const take = Math.min(length - offset, head.length)
321
- head.copy(out, offset, 0, take)
322
- offset += take
323
-
324
- if (take === head.length) {
325
- pending.shift()
326
- } else {
327
- pending[0] = head.subarray(take)
328
- }
329
- pendingLength -= take
330
- }
331
-
332
- return out
333
- }
352
+ const { chunks, totalEncryptedSize } = buildEncryptedChunkPlan(meta)
353
+ const encryptedBuffer = await fetchPublicBinaryWithRetry(downloadBaseUrl, downloadPath, { signal, onStatus })
334
354
 
335
355
  try {
336
- while (nextChunkIndex < chunks.length) {
337
- if (signal?.aborted) {
338
- throw new Error('Decrypt aborted.')
339
- }
340
-
341
- const chunk = chunks[nextChunkIndex]
342
-
343
- while (pendingLength < chunk.encryptedSize) {
344
- const { done, value } = await reader.read()
345
- if (done) break
346
- const buffer = Buffer.from(value)
347
- pending.push(buffer)
348
- pendingLength += buffer.length
349
- downloadedBytes += buffer.length
350
- onProgress?.(Math.min(99, Math.round((downloadedBytes / totalEncryptedSize) * 100)))
351
- }
352
-
353
- const encryptedChunk = takeBytes(chunk.encryptedSize)
354
- if (!encryptedChunk) {
355
- throw new Error('Encrypted file ended before all parts were downloaded.')
356
- }
357
-
358
- const plainPart = await decryptPlainChunkFromEncrypted(
359
- encryptedChunk,
360
- tokenBytes,
361
- noncePrefix,
362
- chunk.chunkIndex,
363
- meta,
364
- )
365
- await fileHandle.write(plainPart)
366
- decryptedBytes += plainPart.length
367
- nextChunkIndex += 1
368
- onProgress?.(Math.min(99, Math.round((decryptedBytes / meta.originalSize) * 100)))
369
- }
370
- } finally {
371
- await reader.cancel().catch(() => {})
372
- await fileHandle.close()
356
+ await writeDecryptedBuffer(encryptedBuffer, chunks, meta, tokenBytes, noncePrefix, outputPath, onProgress, signal)
357
+ } catch (error) {
358
+ await cleanupPartialOutput(outputPath)
359
+ throw error
373
360
  }
374
-
375
- onProgress?.(100)
376
361
  }
377
362
 
378
363
  async function writeDecryptedBuffer(encryptedBuffer, chunks, meta, tokenBytes, noncePrefix, outputPath, onProgress, signal) {
@@ -389,7 +374,7 @@ async function writeDecryptedBuffer(encryptedBuffer, chunks, meta, tokenBytes, n
389
374
  meta,
390
375
  )
391
376
  await fs.writeFile(outputPath, plainPart)
392
- onProgress?.(100)
377
+ onProgress?.({ stage: 'complete', percent: 100 })
393
378
  return
394
379
  }
395
380
 
@@ -418,13 +403,21 @@ async function writeDecryptedBuffer(encryptedBuffer, chunks, meta, tokenBytes, n
418
403
  await fileHandle.write(plainPart)
419
404
  offset += chunk.encryptedSize
420
405
  decryptedBytes += plainPart.length
421
- onProgress?.(Math.round((decryptedBytes / meta.originalSize) * 100))
406
+ onProgress?.(decryptProgress({
407
+ stage: 'decrypting',
408
+ part: chunk.chunkIndex + 1,
409
+ totalParts: chunks.length,
410
+ percent: Math.round((decryptedBytes / meta.originalSize) * 100),
411
+ }))
422
412
  }
413
+ } catch (error) {
414
+ await cleanupPartialOutput(outputPath)
415
+ throw error
423
416
  } finally {
424
417
  await fileHandle.close()
425
418
  }
426
419
 
427
- onProgress?.(100)
420
+ onProgress?.({ stage: 'complete', percent: 100 })
428
421
  }
429
422
 
430
423
  async function decryptFromLocalSingleFile({
@@ -444,7 +437,12 @@ async function decryptFromLocalSingleFile({
444
437
  }
445
438
 
446
439
  const encryptedBuffer = await fs.readFile(encryptedFilePath)
447
- await writeDecryptedBuffer(encryptedBuffer, chunks, meta, tokenBytes, noncePrefix, outputPath, onProgress, signal)
440
+ try {
441
+ await writeDecryptedBuffer(encryptedBuffer, chunks, meta, tokenBytes, noncePrefix, outputPath, onProgress, signal)
442
+ } catch (error) {
443
+ await cleanupPartialOutput(outputPath)
444
+ throw error
445
+ }
448
446
  return
449
447
  }
450
448
 
@@ -452,7 +450,12 @@ async function decryptFromLocalSingleFile({
452
450
 
453
451
  if (stat.size <= 64 * 1024 * 1024) {
454
452
  const encryptedBuffer = await fs.readFile(encryptedFilePath)
455
- await writeDecryptedBuffer(encryptedBuffer, chunks, meta, tokenBytes, noncePrefix, outputPath, onProgress, signal)
453
+ try {
454
+ await writeDecryptedBuffer(encryptedBuffer, chunks, meta, tokenBytes, noncePrefix, outputPath, onProgress, signal)
455
+ } catch (error) {
456
+ await cleanupPartialOutput(outputPath)
457
+ throw error
458
+ }
456
459
  return
457
460
  }
458
461
 
@@ -484,14 +487,22 @@ async function decryptFromLocalSingleFile({
484
487
  await outputHandle.write(plainPart)
485
488
  offset += chunk.encryptedSize
486
489
  decryptedBytes += plainPart.length
487
- onProgress?.(Math.round((decryptedBytes / meta.originalSize) * 100))
490
+ onProgress?.(decryptProgress({
491
+ stage: 'decrypting',
492
+ part: chunk.chunkIndex + 1,
493
+ totalParts: chunks.length,
494
+ percent: Math.round((decryptedBytes / meta.originalSize) * 100),
495
+ }))
488
496
  }
497
+ } catch (error) {
498
+ await cleanupPartialOutput(outputPath)
499
+ throw error
489
500
  } finally {
490
501
  await fileHandle.close()
491
502
  await outputHandle.close()
492
503
  }
493
504
 
494
- onProgress?.(100)
505
+ onProgress?.({ stage: 'complete', percent: 100 })
495
506
  }
496
507
 
497
508
  async function listLocalPartFiles(dirPath) {
@@ -554,13 +565,21 @@ async function decryptFromLocalParts({
554
565
 
555
566
  await fileHandle.write(plainPart)
556
567
  decryptedBytes += plainPart.length
557
- onProgress?.(Math.round((decryptedBytes / meta.originalSize) * 100))
568
+ onProgress?.(decryptProgress({
569
+ stage: 'decrypting',
570
+ part: partNumber,
571
+ totalParts: chunks.length,
572
+ percent: Math.round((decryptedBytes / meta.originalSize) * 100),
573
+ }))
558
574
  }
575
+ } catch (error) {
576
+ await cleanupPartialOutput(outputPath)
577
+ throw error
559
578
  } finally {
560
579
  await fileHandle.close()
561
580
  }
562
581
 
563
- onProgress?.(100)
582
+ onProgress?.({ stage: 'complete', percent: 100 })
564
583
  }
565
584
 
566
585
  function hintTokenConfusion(encryptionToken, linkToken) {
@@ -662,6 +681,20 @@ function buildDecryptResult({
662
681
  }
663
682
  }
664
683
 
684
+ async function ensureOutputPath(outputPath, force) {
685
+ try {
686
+ await fs.access(outputPath)
687
+ if (!force) {
688
+ throw new Error(`Output file already exists: ${outputPath} (use --force to overwrite)`)
689
+ }
690
+ await fs.unlink(outputPath)
691
+ } catch (error) {
692
+ if (error.code !== 'ENOENT') {
693
+ throw error
694
+ }
695
+ }
696
+ }
697
+
665
698
  async function decryptLocalFile(inputPath, options = {}) {
666
699
  const resolvedOptions = await applyUploadJsonDefaults(options)
667
700
  const absolutePath = path.resolve(inputPath)
@@ -681,15 +714,17 @@ async function decryptLocalFile(inputPath, options = {}) {
681
714
  hintTokenConfusion(resolvedOptions.encryptionToken, '')
682
715
  }
683
716
 
717
+ const hasCompleteLocalMeta = Boolean(resolvedOptions.inlineMeta || resolvedOptions.metaPath)
718
+
684
719
  const resolved = await resolveEncryptionMeta({
685
720
  metaPath: resolvedOptions.metaPath,
686
721
  inlineMeta: resolvedOptions.inlineMeta,
687
- linkToken,
722
+ linkToken: hasCompleteLocalMeta ? '' : linkToken,
688
723
  baseUrl: resolvedOptions.baseUrl || shareReference.baseUrl,
689
724
  signal: resolvedOptions.signal,
690
725
  onStatus: resolvedOptions.onStatus,
691
726
  })
692
- const meta = resolved.meta || resolved
727
+ const meta = resolved.meta
693
728
  const linkInfo = resolved.linkInfo || null
694
729
  const encryptionToken = resolveEncryptionToken(
695
730
  resolvedOptions.encryptionToken,
@@ -705,6 +740,7 @@ async function decryptLocalFile(inputPath, options = {}) {
705
740
  )
706
741
 
707
742
  await fs.mkdir(path.dirname(outputPath), { recursive: true })
743
+ await ensureOutputPath(outputPath, resolvedOptions.force)
708
744
 
709
745
  if (stat.isDirectory()) {
710
746
  await decryptFromLocalParts({
@@ -762,7 +798,10 @@ export async function decryptFile(input, options = {}) {
762
798
  throw new Error('Missing share URL, link token, or encrypted file path.')
763
799
  }
764
800
 
765
- if (resolvedOptions.localFile || (!looksLikeRemoteInput(trimmedInput) && await pathExists(trimmedInput))) {
801
+ const treatAsLocal = resolvedOptions.localFile
802
+ || (!looksLikeRemoteInput(trimmedInput) && await pathExists(trimmedInput))
803
+
804
+ if (treatAsLocal) {
766
805
  return decryptLocalFile(trimmedInput, {
767
806
  ...resolvedOptions,
768
807
  onProgress: reportStatus,
@@ -785,7 +824,13 @@ export async function decryptFile(input, options = {}) {
785
824
  throw new Error('This link is not an encrypted file.')
786
825
  }
787
826
 
788
- const meta = normalizeClientEncryptionMeta(linkInfo.clientEncryptionMeta)
827
+ let meta = resolvedOptions.inlineMeta
828
+ if (!meta && resolvedOptions.metaPath) {
829
+ meta = await readMetaFile(resolvedOptions.metaPath)
830
+ }
831
+ if (!meta) {
832
+ meta = normalizeClientEncryptionMeta(linkInfo.clientEncryptionMeta)
833
+ }
789
834
  if (!meta) {
790
835
  throw new Error('Invalid encryption metadata for this file.')
791
836
  }
@@ -800,6 +845,7 @@ export async function decryptFile(input, options = {}) {
800
845
  )
801
846
 
802
847
  await fs.mkdir(path.dirname(outputPath), { recursive: true })
848
+ await ensureOutputPath(outputPath, resolvedOptions.force)
803
849
 
804
850
  if (linkInfo.storageMode === 'parts') {
805
851
  await decryptFromParts({
@@ -812,6 +858,7 @@ export async function decryptFile(input, options = {}) {
812
858
  onProgress: reportStatus,
813
859
  onStatus: reportStatus,
814
860
  signal: resolvedOptions.signal,
861
+ concurrency: resolvedOptions.concurrency,
815
862
  })
816
863
  } else {
817
864
  const encryptedFileUrl = linkInfo.downloadUrl
@@ -826,6 +873,7 @@ export async function decryptFile(input, options = {}) {
826
873
  onProgress: reportStatus,
827
874
  onStatus: reportStatus,
828
875
  signal: resolvedOptions.signal,
876
+ baseUrl: parsed.baseUrl,
829
877
  })
830
878
  }
831
879
 
package/lib/progress.js CHANGED
@@ -24,6 +24,48 @@ export function formatDuration(ms) {
24
24
  return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`
25
25
  }
26
26
 
27
+ export function createProgressTracker({ totalBytes = null } = {}) {
28
+ let lastBytes = 0
29
+ let lastTime = Date.now()
30
+ let smoothedBytesPerSec = 0
31
+
32
+ return {
33
+ update(info = {}) {
34
+ const now = Date.now()
35
+ const bytes = Number(info.downloadedBytes ?? info.bytes ?? 0)
36
+ const elapsedMs = now - lastTime
37
+
38
+ if (elapsedMs >= 250 && bytes >= lastBytes) {
39
+ const instantRate = ((bytes - lastBytes) / elapsedMs) * 1000
40
+ smoothedBytesPerSec = smoothedBytesPerSec === 0
41
+ ? instantRate
42
+ : smoothedBytesPerSec * 0.7 + instantRate * 0.3
43
+ lastBytes = bytes
44
+ lastTime = now
45
+ }
46
+
47
+ const total = Number(info.totalBytes ?? totalBytes)
48
+ let etaMs = null
49
+
50
+ if (Number.isFinite(total) && total > bytes && smoothedBytesPerSec > 0) {
51
+ etaMs = Math.round(((total - bytes) / smoothedBytesPerSec) * 1000)
52
+ }
53
+
54
+ return {
55
+ ...info,
56
+ bytesPerSec: smoothedBytesPerSec > 0 ? smoothedBytesPerSec : undefined,
57
+ etaMs: etaMs ?? undefined,
58
+ }
59
+ },
60
+
61
+ reset() {
62
+ lastBytes = 0
63
+ lastTime = Date.now()
64
+ smoothedBytesPerSec = 0
65
+ },
66
+ }
67
+ }
68
+
27
69
  export function formatProgressLine(info) {
28
70
  if (typeof info === 'number') {
29
71
  return `${info}%`
@@ -55,6 +97,14 @@ export function formatProgressLine(info) {
55
97
  segments.push(`${formatBytes(info.downloadedBytes)}/${formatBytes(info.totalBytes)}`)
56
98
  }
57
99
 
100
+ if (Number.isFinite(info.bytesPerSec) && info.bytesPerSec > 0) {
101
+ segments.push(`${formatBytes(info.bytesPerSec)}/s`)
102
+ }
103
+
104
+ if (Number.isFinite(info.etaMs) && info.etaMs > 0) {
105
+ segments.push(`ETA ${formatDuration(info.etaMs)}`)
106
+ }
107
+
58
108
  if (Number.isFinite(info.percent)) {
59
109
  segments.push(`${info.percent}%`)
60
110
  }