bytifi 0.2.3 → 0.2.5

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/bin/bytifi.js CHANGED
@@ -5,6 +5,7 @@ import path from 'node:path'
5
5
  import process from 'node:process'
6
6
  import { createRequire } from 'node:module'
7
7
  import { decryptFile } from '../lib/decrypt.js'
8
+ import { formatProgressLine } from '../lib/progress.js'
8
9
  import { BytifiApiError, BytifiNetworkError, uploadFile } from '../lib/upload.js'
9
10
 
10
11
  const require = createRequire(import.meta.url)
@@ -287,8 +288,15 @@ function validateExpires(minutes) {
287
288
  }
288
289
  }
289
290
 
290
- function writeProgress(label, percent) {
291
- process.stderr.write(`\r${label}: ${percent}%`)
291
+ function writeProgress(info) {
292
+ const line = formatProgressLine(info)
293
+ if (line) {
294
+ process.stderr.write(`\r${line}`)
295
+ }
296
+ }
297
+
298
+ function finishProgressLine() {
299
+ process.stderr.write('\n')
292
300
  }
293
301
 
294
302
  function clearProgressLine() {
@@ -320,7 +328,7 @@ async function runUpload(filePath, options) {
320
328
  process.on('SIGTERM', handleSignal)
321
329
 
322
330
  const showProgress = !options.quiet && !options.json
323
- let lastPercent = -1
331
+ let lastLine = ''
324
332
 
325
333
  try {
326
334
  const result = await uploadFile(resolvedPath, {
@@ -332,17 +340,18 @@ async function runUpload(filePath, options) {
332
340
  concurrency: options.concurrency,
333
341
  signal: abortController.signal,
334
342
  onProgress: showProgress
335
- ? (percent) => {
336
- if (percent !== lastPercent) {
337
- lastPercent = percent
338
- writeProgress('Encrypting and uploading', percent)
343
+ ? (info) => {
344
+ const line = formatProgressLine(info)
345
+ if (line && line !== lastLine) {
346
+ lastLine = line
347
+ writeProgress(info)
339
348
  }
340
349
  }
341
350
  : undefined,
342
351
  })
343
352
 
344
353
  if (showProgress) {
345
- clearProgressLine()
354
+ finishProgressLine()
346
355
  }
347
356
 
348
357
  if (options.json) {
@@ -375,7 +384,7 @@ async function runDecrypt(input, options) {
375
384
  process.on('SIGTERM', handleSignal)
376
385
 
377
386
  const showProgress = !options.quiet && !options.json
378
- let lastPercent = -1
387
+ let lastLine = ''
379
388
 
380
389
  try {
381
390
  const result = await decryptFile(input, {
@@ -389,17 +398,18 @@ async function runDecrypt(input, options) {
389
398
  baseUrl: options.baseUrl,
390
399
  signal: abortController.signal,
391
400
  onProgress: showProgress
392
- ? (percent) => {
393
- if (percent !== lastPercent) {
394
- lastPercent = percent
395
- writeProgress('Downloading and decrypting', percent)
401
+ ? (info) => {
402
+ const line = formatProgressLine(info)
403
+ if (line && line !== lastLine) {
404
+ lastLine = line
405
+ writeProgress(info)
396
406
  }
397
407
  }
398
408
  : undefined,
399
409
  })
400
410
 
401
411
  if (showProgress) {
402
- clearProgressLine()
412
+ finishProgressLine()
403
413
  }
404
414
 
405
415
  if (options.json) {
@@ -531,6 +541,7 @@ async function main() {
531
541
 
532
542
  main().catch((error) => {
533
543
  clearProgressLine()
544
+ finishProgressLine()
534
545
  const verbose = process.argv.includes('--verbose')
535
546
  printError(error, verbose)
536
547
  process.exit(exitCodeForError(error))
package/lib/decrypt.js CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  usesChunkCompression,
10
10
  } from './crypto.js'
11
11
  import { fromBase64Url } from './base64url.js'
12
- import { BytifiApiError, BytifiNetworkError } from './upload.js'
12
+ import { BytifiApiError, fetchPublicBinaryWithRetry, publicFetchWithRetry } from './upload.js'
13
13
 
14
14
  const DEFAULT_BASE_URL = 'https://bytifi.com'
15
15
 
@@ -17,45 +17,6 @@ function normalizeBaseUrl(baseUrl) {
17
17
  return String(baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '')
18
18
  }
19
19
 
20
- async function readResponseBody(response) {
21
- const text = await response.text()
22
- if (!text) return null
23
-
24
- try {
25
- return JSON.parse(text)
26
- } catch {
27
- return text
28
- }
29
- }
30
-
31
- async function publicFetch(baseUrl, requestPath, { signal } = {}) {
32
- const url = `${normalizeBaseUrl(baseUrl)}${requestPath}`
33
-
34
- let response
35
-
36
- try {
37
- response = await fetch(url, { signal })
38
- } catch (error) {
39
- if (signal?.aborted) {
40
- throw new Error('Decrypt aborted.')
41
- }
42
- throw new BytifiNetworkError(error.message || 'Network request failed.', { cause: error })
43
- }
44
-
45
- const payload = await readResponseBody(response)
46
-
47
- if (!response.ok) {
48
- const message = typeof payload === 'object' && payload?.error
49
- ? payload.error
50
- : typeof payload === 'string' && payload
51
- ? payload
52
- : `Request failed with status ${response.status}.`
53
- throw new BytifiApiError(message, { status: response.status, body: payload })
54
- }
55
-
56
- return payload
57
- }
58
-
59
20
  export function parseDecryptInput(input, { encryptionToken = '', baseUrl = DEFAULT_BASE_URL } = {}) {
60
21
  const trimmed = String(input || '').trim()
61
22
  if (!trimmed) {
@@ -168,6 +129,7 @@ async function resolveEncryptionMeta({
168
129
  linkToken = '',
169
130
  baseUrl = DEFAULT_BASE_URL,
170
131
  signal,
132
+ onStatus,
171
133
  }) {
172
134
  if (inlineMeta) {
173
135
  return { meta: inlineMeta }
@@ -184,7 +146,7 @@ async function resolveEncryptionMeta({
184
146
  )
185
147
  }
186
148
 
187
- const linkInfo = await fetchLinkInfo(baseUrl, linkToken, signal)
149
+ const linkInfo = await fetchLinkInfo(baseUrl, linkToken, signal, onStatus)
188
150
 
189
151
  if (linkInfo.status === 'expired') {
190
152
  throw new Error('This file link has expired.')
@@ -207,25 +169,16 @@ function sanitizeOutputName(filename) {
207
169
  return base || 'download'
208
170
  }
209
171
 
210
- async function fetchLinkInfo(baseUrl, linkToken, signal) {
211
- return publicFetch(baseUrl, `/api/link/${encodeURIComponent(linkToken)}`, { signal })
172
+ async function fetchLinkInfo(baseUrl, linkToken, signal, onStatus) {
173
+ return publicFetchWithRetry(baseUrl, `/api/link/${encodeURIComponent(linkToken)}`, { signal, onStatus })
212
174
  }
213
175
 
214
- async function fetchEncryptedPart(baseUrl, linkToken, partNumber, signal) {
215
- const response = await fetch(
216
- `${normalizeBaseUrl(baseUrl)}/f/${encodeURIComponent(linkToken)}/p/${partNumber}`,
217
- { signal },
176
+ async function fetchEncryptedPart(baseUrl, linkToken, partNumber, signal, onStatus) {
177
+ return fetchPublicBinaryWithRetry(
178
+ baseUrl,
179
+ `/f/${encodeURIComponent(linkToken)}/p/${partNumber}`,
180
+ { signal, onStatus },
218
181
  )
219
-
220
- if (!response.ok) {
221
- const text = await response.text()
222
- throw new BytifiApiError(text || `Failed to download part ${partNumber}.`, {
223
- status: response.status,
224
- body: text,
225
- })
226
- }
227
-
228
- return Buffer.from(await response.arrayBuffer())
229
182
  }
230
183
 
231
184
  async function decryptFromParts({
@@ -236,6 +189,7 @@ async function decryptFromParts({
236
189
  noncePrefix,
237
190
  outputPath,
238
191
  onProgress,
192
+ onStatus,
239
193
  signal,
240
194
  }) {
241
195
  const { chunks } = buildEncryptedChunkPlan(meta)
@@ -249,7 +203,27 @@ async function decryptFromParts({
249
203
  }
250
204
 
251
205
  const chunk = chunks[index]
252
- const encryptedPart = await fetchEncryptedPart(baseUrl, linkToken, chunk.chunkIndex + 1, signal)
206
+ const partNumber = chunk.chunkIndex + 1
207
+
208
+ onProgress?.({
209
+ stage: 'downloading',
210
+ part: partNumber,
211
+ totalParts: chunks.length,
212
+ downloadedBytes: decryptedBytes,
213
+ totalBytes: meta.originalSize,
214
+ percent: Math.round((decryptedBytes / meta.originalSize) * 100),
215
+ })
216
+
217
+ const encryptedPart = await fetchEncryptedPart(baseUrl, linkToken, partNumber, signal, onStatus)
218
+
219
+ onProgress?.({
220
+ stage: 'decrypting',
221
+ part: partNumber,
222
+ totalParts: chunks.length,
223
+ partBytes: encryptedPart.length,
224
+ percent: Math.round((decryptedBytes / meta.originalSize) * 100),
225
+ })
226
+
253
227
  const plainPart = await decryptPlainChunkFromEncrypted(
254
228
  encryptedPart,
255
229
  tokenBytes,
@@ -260,11 +234,22 @@ async function decryptFromParts({
260
234
 
261
235
  await fileHandle.write(plainPart)
262
236
  decryptedBytes += plainPart.length
263
- onProgress?.(Math.round((decryptedBytes / meta.originalSize) * 100))
237
+
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
+ })
264
247
  }
265
248
  } finally {
266
249
  await fileHandle.close()
267
250
  }
251
+
252
+ onProgress?.({ stage: 'complete', percent: 100 })
268
253
  }
269
254
 
270
255
  async function decryptFromSingleFile({
@@ -702,6 +687,7 @@ async function decryptLocalFile(inputPath, options = {}) {
702
687
  linkToken,
703
688
  baseUrl: resolvedOptions.baseUrl || shareReference.baseUrl,
704
689
  signal: resolvedOptions.signal,
690
+ onStatus: resolvedOptions.onStatus,
705
691
  })
706
692
  const meta = resolved.meta || resolved
707
693
  const linkInfo = resolved.linkInfo || null
@@ -767,13 +753,21 @@ async function decryptLocalFile(inputPath, options = {}) {
767
753
 
768
754
  export async function decryptFile(input, options = {}) {
769
755
  const resolvedOptions = await applyUploadJsonDefaults(options)
756
+ const reportStatus = (info) => {
757
+ resolvedOptions.onStatus?.(info)
758
+ resolvedOptions.onProgress?.(info)
759
+ }
770
760
  const trimmedInput = String(input || '').trim()
771
761
  if (!trimmedInput) {
772
762
  throw new Error('Missing share URL, link token, or encrypted file path.')
773
763
  }
774
764
 
775
765
  if (resolvedOptions.localFile || (!looksLikeRemoteInput(trimmedInput) && await pathExists(trimmedInput))) {
776
- return decryptLocalFile(trimmedInput, resolvedOptions)
766
+ return decryptLocalFile(trimmedInput, {
767
+ ...resolvedOptions,
768
+ onProgress: reportStatus,
769
+ onStatus: reportStatus,
770
+ })
777
771
  }
778
772
 
779
773
  const parsed = parseDecryptInput(trimmedInput, {
@@ -781,7 +775,7 @@ export async function decryptFile(input, options = {}) {
781
775
  baseUrl: resolvedOptions.baseUrl,
782
776
  })
783
777
 
784
- const linkInfo = await fetchLinkInfo(parsed.baseUrl, parsed.linkToken, resolvedOptions.signal)
778
+ const linkInfo = await fetchLinkInfo(parsed.baseUrl, parsed.linkToken, resolvedOptions.signal, reportStatus)
785
779
 
786
780
  if (linkInfo.status === 'expired') {
787
781
  throw new Error('This file link has expired.')
@@ -815,7 +809,8 @@ export async function decryptFile(input, options = {}) {
815
809
  tokenBytes,
816
810
  noncePrefix,
817
811
  outputPath,
818
- onProgress: resolvedOptions.onProgress,
812
+ onProgress: reportStatus,
813
+ onStatus: reportStatus,
819
814
  signal: resolvedOptions.signal,
820
815
  })
821
816
  } else {
@@ -828,7 +823,8 @@ export async function decryptFile(input, options = {}) {
828
823
  tokenBytes,
829
824
  noncePrefix,
830
825
  outputPath,
831
- onProgress: resolvedOptions.onProgress,
826
+ onProgress: reportStatus,
827
+ onStatus: reportStatus,
832
828
  signal: resolvedOptions.signal,
833
829
  })
834
830
  }
@@ -0,0 +1,67 @@
1
+ export function formatBytes(bytes) {
2
+ const value = Number(bytes)
3
+ if (!Number.isFinite(value) || value < 0) return '0 B'
4
+
5
+ const units = ['B', 'KB', 'MB', 'GB', 'TB']
6
+ let size = value
7
+ let unitIndex = 0
8
+
9
+ while (size >= 1024 && unitIndex < units.length - 1) {
10
+ size /= 1024
11
+ unitIndex += 1
12
+ }
13
+
14
+ const digits = size >= 100 || unitIndex === 0 ? 0 : size >= 10 ? 1 : 2
15
+ return `${size.toFixed(digits)} ${units[unitIndex]}`
16
+ }
17
+
18
+ export function formatDuration(ms) {
19
+ const totalSeconds = Math.max(1, Math.ceil(Number(ms) / 1000))
20
+ if (totalSeconds < 60) return `${totalSeconds}s`
21
+
22
+ const minutes = Math.floor(totalSeconds / 60)
23
+ const seconds = totalSeconds % 60
24
+ return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`
25
+ }
26
+
27
+ export function formatProgressLine(info) {
28
+ if (typeof info === 'number') {
29
+ return `${info}%`
30
+ }
31
+
32
+ if (!info || typeof info !== 'object') {
33
+ return ''
34
+ }
35
+
36
+ if (info.stage === 'waiting') {
37
+ return info.message || 'Waiting…'
38
+ }
39
+
40
+ const segments = []
41
+
42
+ if (info.part && info.totalParts) {
43
+ segments.push(`part ${info.part}/${info.totalParts}`)
44
+ }
45
+
46
+ if (info.stage) {
47
+ segments.push(info.stage)
48
+ }
49
+
50
+ if (info.partBytes) {
51
+ segments.push(formatBytes(info.partBytes))
52
+ }
53
+
54
+ if (Number.isFinite(info.downloadedBytes) && Number.isFinite(info.totalBytes)) {
55
+ segments.push(`${formatBytes(info.downloadedBytes)}/${formatBytes(info.totalBytes)}`)
56
+ }
57
+
58
+ if (Number.isFinite(info.percent)) {
59
+ segments.push(`${info.percent}%`)
60
+ }
61
+
62
+ if (info.detail) {
63
+ segments.push(info.detail)
64
+ }
65
+
66
+ return segments.join(' · ')
67
+ }
package/lib/upload.js CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  encryptChunkFromFile,
4
4
  resolveUploadFile,
5
5
  } from './crypto.js'
6
+ import { formatDuration } from './progress.js'
6
7
  import fs from 'node:fs/promises'
7
8
 
8
9
  const DEFAULT_BASE_URL = 'https://bytifi.com'
@@ -10,13 +11,15 @@ const DEFAULT_CONCURRENCY = 4
10
11
  const POLL_INTERVAL_MS = 1500
11
12
  const POLL_TIMEOUT_MS = 30 * 60 * 1000
12
13
  const MAX_RETRIES = 3
14
+ const UNLIMITED_RATE_LIMIT_RETRIES = Number.POSITIVE_INFINITY
13
15
 
14
16
  export class BytifiApiError extends Error {
15
- constructor(message, { status = 0, body = null } = {}) {
17
+ constructor(message, { status = 0, body = null, retryAfterMs = null } = {}) {
16
18
  super(message)
17
19
  this.name = 'BytifiApiError'
18
20
  this.status = status
19
21
  this.body = body
22
+ this.retryAfterMs = retryAfterMs
20
23
  }
21
24
  }
22
25
 
@@ -44,6 +47,34 @@ function isRetryableError(error) {
44
47
  return false
45
48
  }
46
49
 
50
+ function parseRetryAfterMs(response) {
51
+ const retryAfter = response.headers.get('Retry-After')
52
+ if (retryAfter) {
53
+ const seconds = Number(retryAfter)
54
+ if (Number.isFinite(seconds) && seconds >= 0) {
55
+ return seconds * 1000
56
+ }
57
+
58
+ const dateMs = Date.parse(retryAfter)
59
+ if (Number.isFinite(dateMs)) {
60
+ return Math.max(0, dateMs - Date.now())
61
+ }
62
+ }
63
+
64
+ const resetHeader = response.headers.get('RateLimit-Reset')
65
+ if (resetHeader) {
66
+ const resetValue = Number(resetHeader)
67
+ if (Number.isFinite(resetValue)) {
68
+ if (resetValue > 1_000_000_000) {
69
+ return Math.max(0, resetValue * 1000 - Date.now())
70
+ }
71
+ return Math.max(0, resetValue * 1000)
72
+ }
73
+ }
74
+
75
+ return null
76
+ }
77
+
47
78
  async function readResponseBody(response) {
48
79
  const text = await response.text()
49
80
  if (!text) return null
@@ -55,13 +86,16 @@ async function readResponseBody(response) {
55
86
  }
56
87
  }
57
88
 
58
- async function apiFetch(baseUrl, path, { apiKey, method = 'GET', headers = {}, body = null, signal } = {}) {
89
+ async function apiFetch(baseUrl, path, { apiKey, method = 'GET', headers = {}, body = null, signal, binary = false } = {}) {
59
90
  const url = `${normalizeBaseUrl(baseUrl)}${path}`
60
91
  const requestHeaders = {
61
- Authorization: `Bearer ${apiKey}`,
62
92
  ...headers,
63
93
  }
64
94
 
95
+ if (apiKey) {
96
+ requestHeaders.Authorization = `Bearer ${apiKey}`
97
+ }
98
+
65
99
  let response
66
100
 
67
101
  try {
@@ -78,41 +112,110 @@ async function apiFetch(baseUrl, path, { apiKey, method = 'GET', headers = {}, b
78
112
  throw new BytifiNetworkError(error.message || 'Network request failed.', { cause: error })
79
113
  }
80
114
 
81
- const payload = await readResponseBody(response)
115
+ const responseBuffer = Buffer.from(await response.arrayBuffer())
82
116
 
83
117
  if (!response.ok) {
118
+ const text = responseBuffer.toString('utf8')
119
+ let payload = text
120
+ try {
121
+ payload = JSON.parse(text)
122
+ } catch {
123
+ // keep text
124
+ }
125
+
84
126
  const message = typeof payload === 'object' && payload?.error
85
127
  ? payload.error
86
128
  : typeof payload === 'string' && payload
87
129
  ? payload
88
130
  : `Request failed with status ${response.status}.`
89
- throw new BytifiApiError(message, { status: response.status, body: payload })
131
+ throw new BytifiApiError(message, {
132
+ status: response.status,
133
+ body: payload,
134
+ retryAfterMs: response.status === 429 ? parseRetryAfterMs(response) : null,
135
+ })
90
136
  }
91
137
 
92
- return payload
138
+ if (binary) {
139
+ return responseBuffer
140
+ }
141
+
142
+ const text = responseBuffer.toString('utf8')
143
+ if (!text) return null
144
+
145
+ try {
146
+ return JSON.parse(text)
147
+ } catch {
148
+ return text
149
+ }
93
150
  }
94
151
 
95
152
  async function apiFetchWithRetry(baseUrl, path, options = {}) {
96
- const { retries = MAX_RETRIES, signal } = options
153
+ const {
154
+ retries = MAX_RETRIES,
155
+ rateLimitRetries = 0,
156
+ onStatus,
157
+ signal,
158
+ ...fetchOptions
159
+ } = options
160
+
161
+ let attempt = 0
162
+ let rateLimitAttempt = 0
163
+ const maxRateLimitRetries = rateLimitRetries === UNLIMITED_RATE_LIMIT_RETRIES
164
+ ? UNLIMITED_RATE_LIMIT_RETRIES
165
+ : (rateLimitRetries || retries)
97
166
 
98
- for (let attempt = 0; attempt <= retries; attempt += 1) {
167
+ while (true) {
99
168
  try {
100
- return await apiFetch(baseUrl, path, options)
169
+ return await apiFetch(baseUrl, path, { ...fetchOptions, signal })
101
170
  } catch (error) {
102
- if (signal?.aborted || error.message === 'Upload aborted.') {
171
+ if (signal?.aborted || error.message === 'Upload aborted.' || error.message === 'Decrypt aborted.') {
103
172
  throw error
104
173
  }
105
174
 
106
- if (!isRetryableError(error) || attempt === retries) {
175
+ if (error instanceof BytifiApiError && error.status === 429) {
176
+ if (rateLimitAttempt >= maxRateLimitRetries) {
177
+ throw error
178
+ }
179
+
180
+ rateLimitAttempt += 1
181
+ const waitMs = error.retryAfterMs ?? Math.min(600_000, 15_000 * rateLimitAttempt)
182
+
183
+ onStatus?.({
184
+ stage: 'waiting',
185
+ message: `Rate limited — waiting ${formatDuration(waitMs)} before retry (${rateLimitAttempt})…`,
186
+ waitMs,
187
+ retryAttempt: rateLimitAttempt,
188
+ })
189
+
190
+ await sleep(waitMs)
191
+ continue
192
+ }
193
+
194
+ if (!isRetryableError(error) || attempt >= retries) {
107
195
  throw error
108
196
  }
109
197
 
110
- const delayMs = Math.min(1000 * (2 ** attempt), 8000)
111
- await sleep(delayMs)
198
+ attempt += 1
199
+ await sleep(Math.min(1000 * (2 ** attempt), 8000))
112
200
  }
113
201
  }
202
+ }
114
203
 
115
- throw new Error('Request failed after retries.')
204
+ export async function publicFetchWithRetry(baseUrl, path, options = {}) {
205
+ return apiFetchWithRetry(baseUrl, path, {
206
+ ...options,
207
+ apiKey: null,
208
+ rateLimitRetries: options.rateLimitRetries ?? UNLIMITED_RATE_LIMIT_RETRIES,
209
+ })
210
+ }
211
+
212
+ export async function fetchPublicBinaryWithRetry(baseUrl, path, options = {}) {
213
+ return apiFetchWithRetry(baseUrl, path, {
214
+ ...options,
215
+ apiKey: null,
216
+ binary: true,
217
+ rateLimitRetries: options.rateLimitRetries ?? UNLIMITED_RATE_LIMIT_RETRIES,
218
+ })
116
219
  }
117
220
 
118
221
  function buildShareUrl(payload, encryptionToken) {
@@ -136,69 +239,6 @@ function buildResult(payload, context, shareUrl) {
136
239
  }
137
240
  }
138
241
 
139
- function createBoundedQueue(maxSize) {
140
- const items = []
141
- const waiters = []
142
- let closed = false
143
-
144
- function notifyWaiters() {
145
- while (waiters.length > 0) {
146
- const resolve = waiters.shift()
147
- resolve()
148
- }
149
- }
150
-
151
- return {
152
- async push(item, signal) {
153
- while (items.length >= maxSize) {
154
- if (signal?.aborted) {
155
- throw new Error('Upload aborted.')
156
- }
157
- await new Promise((resolve, reject) => {
158
- const onAbort = () => {
159
- const index = waiters.indexOf(resolve)
160
- if (index >= 0) waiters.splice(index, 1)
161
- reject(new Error('Upload aborted.'))
162
- }
163
- waiters.push(resolve)
164
- signal?.addEventListener('abort', onAbort, { once: true })
165
- })
166
- }
167
-
168
- items.push(item)
169
- notifyWaiters()
170
- },
171
- async take(signal) {
172
- while (items.length === 0) {
173
- if (closed) {
174
- return null
175
- }
176
- if (signal?.aborted) {
177
- throw new Error('Upload aborted.')
178
- }
179
- await new Promise((resolve, reject) => {
180
- const onAbort = () => {
181
- const index = waiters.indexOf(resolve)
182
- if (index >= 0) waiters.splice(index, 1)
183
- reject(new Error('Upload aborted.'))
184
- }
185
- waiters.push(resolve)
186
- signal?.addEventListener('abort', onAbort, { once: true })
187
- })
188
- }
189
-
190
- return items.shift()
191
- },
192
- close() {
193
- closed = true
194
- notifyWaiters()
195
- },
196
- get pending() {
197
- return items.length
198
- },
199
- }
200
- }
201
-
202
242
  async function collectEncryptedBuffer(filePath, context, { onProgress, signal } = {}) {
203
243
  const fileHandle = await fs.open(filePath, 'r')
204
244
  const encryptedParts = []
@@ -209,14 +249,26 @@ async function collectEncryptedBuffer(filePath, context, { onProgress, signal }
209
249
  throw new Error('Upload aborted.')
210
250
  }
211
251
 
252
+ onProgress?.({
253
+ stage: 'encrypting',
254
+ part: chunkIndex + 1,
255
+ totalParts: context.chunkCount,
256
+ percent: Math.round((chunkIndex / context.chunkCount) * 90),
257
+ })
258
+
212
259
  encryptedParts.push(await encryptChunkFromFile(fileHandle, chunkIndex, context))
213
- onProgress?.(Math.round(((chunkIndex + 1) / context.chunkCount) * 90))
260
+ onProgress?.({
261
+ stage: 'encrypted',
262
+ part: chunkIndex + 1,
263
+ totalParts: context.chunkCount,
264
+ percent: Math.round(((chunkIndex + 1) / context.chunkCount) * 90),
265
+ })
214
266
  }
215
267
  } finally {
216
268
  await fileHandle.close()
217
269
  }
218
270
 
219
- onProgress?.(95)
271
+ onProgress?.({ stage: 'uploading', percent: 95, detail: 'direct upload' })
220
272
  return Buffer.concat(encryptedParts)
221
273
  }
222
274
 
@@ -226,6 +278,7 @@ async function uploadDirect(context, encryptedBuffer, {
226
278
  expiresInMinutes,
227
279
  deleteOnDownload,
228
280
  onProgress,
281
+ onStatus,
229
282
  signal,
230
283
  }) {
231
284
  const formData = new FormData()
@@ -242,15 +295,17 @@ async function uploadDirect(context, encryptedBuffer, {
242
295
  method: 'POST',
243
296
  body: formData,
244
297
  signal,
298
+ onStatus,
299
+ rateLimitRetries: UNLIMITED_RATE_LIMIT_RETRIES,
245
300
  })
246
301
 
247
- onProgress?.(100)
302
+ onProgress?.({ stage: 'complete', percent: 100 })
248
303
 
249
304
  const shareUrl = buildShareUrl(payload, context.token)
250
305
  return buildResult(payload, context, shareUrl)
251
306
  }
252
307
 
253
- async function pollUploadStatus(sessionToken, { apiKey, baseUrl, signal }) {
308
+ async function pollUploadStatus(sessionToken, { apiKey, baseUrl, signal, onStatus }) {
254
309
  const startedAt = Date.now()
255
310
 
256
311
  while (true) {
@@ -265,27 +320,57 @@ async function pollUploadStatus(sessionToken, { apiKey, baseUrl, signal }) {
265
320
  const payload = await apiFetchWithRetry(
266
321
  baseUrl,
267
322
  `/api/public/upload/status?sessionToken=${encodeURIComponent(sessionToken)}`,
268
- { apiKey, signal },
323
+ {
324
+ apiKey,
325
+ signal,
326
+ onStatus,
327
+ rateLimitRetries: UNLIMITED_RATE_LIMIT_RETRIES,
328
+ },
269
329
  )
270
330
 
271
331
  if (payload.status !== 'processing' && payload.status !== 'pending') {
272
332
  return payload
273
333
  }
274
334
 
335
+ onStatus?.({
336
+ stage: 'finalizing',
337
+ percent: Math.min(99, Math.round(Number(payload?.progress?.percent || 0))),
338
+ detail: payload?.progress?.phase || 'processing on server',
339
+ })
340
+
275
341
  await sleep(POLL_INTERVAL_MS)
276
342
  }
277
343
  }
278
344
 
345
+ function resolveUploadConcurrency(originalSize, requested) {
346
+ const requestedValue = Number(requested)
347
+ if (Number.isFinite(requestedValue) && requestedValue > 0) {
348
+ return Math.min(16, Math.max(1, Math.floor(requestedValue)))
349
+ }
350
+
351
+ if (originalSize >= 3 * 1024 * 1024 * 1024) return 2
352
+ if (originalSize >= 1024 * 1024 * 1024) return 3
353
+ return DEFAULT_CONCURRENCY
354
+ }
355
+
279
356
  async function uploadMultipartStreaming(filePath, context, {
280
357
  apiKey,
281
358
  baseUrl,
282
359
  expiresInMinutes,
283
360
  deleteOnDownload,
284
361
  onProgress,
362
+ onStatus,
285
363
  signal,
286
364
  concurrency = DEFAULT_CONCURRENCY,
287
365
  }) {
288
366
  const maxPartSize = context.meta.chunkSize + 16
367
+
368
+ onProgress?.({
369
+ stage: 'starting',
370
+ percent: 0,
371
+ detail: `${context.chunkCount} parts · ${context.compression === 'gzip' ? 'gzip' : 'raw'} chunks`,
372
+ })
373
+
289
374
  const initPayload = await apiFetchWithRetry(baseUrl, '/api/public/upload/init', {
290
375
  apiKey,
291
376
  method: 'POST',
@@ -302,25 +387,32 @@ async function uploadMultipartStreaming(filePath, context, {
302
387
  deleteOnDownload,
303
388
  }),
304
389
  signal,
390
+ onStatus,
391
+ rateLimitRetries: UNLIMITED_RATE_LIMIT_RETRIES,
305
392
  })
306
393
 
307
394
  const sessionToken = initPayload.sessionToken
308
395
  const workerCount = Math.min(
309
- Number(concurrency) || Number(initPayload.concurrency) || DEFAULT_CONCURRENCY,
396
+ resolveUploadConcurrency(context.originalSize, concurrency || initPayload.concurrency),
310
397
  context.chunkCount,
311
398
  )
312
- const queue = createBoundedQueue(Math.max(2, workerCount * 2))
313
399
  const fileHandle = await fs.open(filePath, 'r')
314
400
  let nextChunkIndex = 0
315
401
  let completedParts = 0
316
402
 
403
+ onProgress?.({
404
+ stage: 'uploading',
405
+ percent: 0,
406
+ detail: `${workerCount} workers · session ${sessionToken.slice(0, 8)}…`,
407
+ })
408
+
317
409
  function claimChunkIndex() {
318
410
  const chunkIndex = nextChunkIndex
319
411
  nextChunkIndex += 1
320
412
  return chunkIndex
321
413
  }
322
414
 
323
- async function encryptWorker() {
415
+ async function pipelineWorker() {
324
416
  while (true) {
325
417
  if (signal?.aborted) {
326
418
  throw new Error('Upload aborted.')
@@ -331,74 +423,87 @@ async function uploadMultipartStreaming(filePath, context, {
331
423
  return
332
424
  }
333
425
 
334
- const encryptedPart = await encryptChunkFromFile(fileHandle, chunkIndex, context)
335
- await queue.push({
336
- partNumber: chunkIndex + 1,
337
- body: encryptedPart,
338
- }, signal)
339
- }
340
- }
426
+ const partNumber = chunkIndex + 1
341
427
 
342
- async function uploadWorker() {
343
- while (true) {
344
- if (signal?.aborted) {
345
- throw new Error('Upload aborted.')
346
- }
428
+ onProgress?.({
429
+ stage: 'encrypting',
430
+ part: partNumber,
431
+ totalParts: context.chunkCount,
432
+ percent: Math.round((completedParts / context.chunkCount) * 100),
433
+ })
347
434
 
348
- const part = await queue.take(signal)
349
- if (!part) {
350
- return
351
- }
435
+ const encryptedPart = await encryptChunkFromFile(fileHandle, chunkIndex, context)
436
+
437
+ onProgress?.({
438
+ stage: 'uploading',
439
+ part: partNumber,
440
+ totalParts: context.chunkCount,
441
+ partBytes: encryptedPart.length,
442
+ percent: Math.round((completedParts / context.chunkCount) * 100),
443
+ })
352
444
 
353
445
  await apiFetchWithRetry(
354
446
  baseUrl,
355
- `/api/public/upload/part?sessionToken=${encodeURIComponent(sessionToken)}&partNumber=${part.partNumber}`,
447
+ `/api/public/upload/part?sessionToken=${encodeURIComponent(sessionToken)}&partNumber=${partNumber}`,
356
448
  {
357
449
  apiKey,
358
450
  method: 'PUT',
359
451
  headers: { 'Content-Type': 'application/octet-stream' },
360
- body: part.body,
452
+ body: encryptedPart,
361
453
  signal,
454
+ onStatus,
455
+ rateLimitRetries: UNLIMITED_RATE_LIMIT_RETRIES,
362
456
  },
363
457
  )
364
458
 
365
459
  completedParts += 1
366
- onProgress?.(Math.round((completedParts / context.chunkCount) * 100))
460
+ onProgress?.({
461
+ stage: 'uploaded',
462
+ part: partNumber,
463
+ totalParts: context.chunkCount,
464
+ partBytes: encryptedPart.length,
465
+ percent: Math.round((completedParts / context.chunkCount) * 100),
466
+ detail: `${completedParts}/${context.chunkCount} parts done`,
467
+ })
367
468
  }
368
469
  }
369
470
 
370
471
  try {
371
- const uploadWorkers = Array.from({ length: workerCount }, () => uploadWorker())
372
- const encryptWorkers = Array.from({ length: workerCount }, () => encryptWorker())
373
- await Promise.all(encryptWorkers)
374
- queue.close()
375
- await Promise.all(uploadWorkers)
472
+ await Promise.all(Array.from({ length: workerCount }, () => pipelineWorker()))
376
473
  } catch (error) {
377
- await apiFetchWithRetry(baseUrl, '/api/public/upload/abort', {
378
- apiKey,
379
- method: 'POST',
380
- headers: { 'Content-Type': 'application/json' },
381
- body: JSON.stringify({ sessionToken }),
382
- signal,
383
- }).catch(() => {})
474
+ if (!(error instanceof BytifiApiError && error.status === 429)) {
475
+ await apiFetchWithRetry(baseUrl, '/api/public/upload/abort', {
476
+ apiKey,
477
+ method: 'POST',
478
+ headers: { 'Content-Type': 'application/json' },
479
+ body: JSON.stringify({ sessionToken }),
480
+ signal,
481
+ }).catch(() => {})
482
+ }
384
483
 
385
484
  throw error
386
485
  } finally {
387
486
  await fileHandle.close()
388
487
  }
389
488
 
489
+ onProgress?.({ stage: 'finalizing', percent: 99, detail: 'completing upload session' })
490
+
390
491
  let completePayload = await apiFetchWithRetry(baseUrl, '/api/public/upload/complete', {
391
492
  apiKey,
392
493
  method: 'POST',
393
494
  headers: { 'Content-Type': 'application/json' },
394
495
  body: JSON.stringify({ sessionToken }),
395
496
  signal,
497
+ onStatus,
498
+ rateLimitRetries: UNLIMITED_RATE_LIMIT_RETRIES,
396
499
  })
397
500
 
398
501
  if (completePayload.status === 'processing' || completePayload.status === 'pending') {
399
- completePayload = await pollUploadStatus(sessionToken, { apiKey, baseUrl, signal })
502
+ completePayload = await pollUploadStatus(sessionToken, { apiKey, baseUrl, signal, onStatus })
400
503
  }
401
504
 
505
+ onProgress?.({ stage: 'complete', percent: 100 })
506
+
402
507
  const shareUrl = buildShareUrl(completePayload, context.token)
403
508
  return buildResult(completePayload, context, shareUrl)
404
509
  }
@@ -408,18 +513,28 @@ export async function uploadFile(filePath, options) {
408
513
  throw new Error('API key is required.')
409
514
  }
410
515
 
516
+ const reportStatus = (info) => {
517
+ options.onStatus?.(info)
518
+ options.onProgress?.(info)
519
+ }
520
+
521
+ const uploadOptions = {
522
+ ...options,
523
+ onStatus: reportStatus,
524
+ }
525
+
411
526
  const { absolutePath, context } = await resolveUploadFile(filePath, {
412
527
  mimeType: options.mimeType,
413
528
  })
414
529
 
415
530
  if (context.originalSize <= MULTIPART_THRESHOLD_BYTES) {
416
531
  const encryptedBuffer = await collectEncryptedBuffer(absolutePath, context, {
417
- onProgress: options.onProgress,
532
+ onProgress: reportStatus,
418
533
  signal: options.signal,
419
534
  })
420
535
 
421
- return uploadDirect(context, encryptedBuffer, options)
536
+ return uploadDirect(context, encryptedBuffer, uploadOptions)
422
537
  }
423
538
 
424
- return uploadMultipartStreaming(absolutePath, context, options)
539
+ return uploadMultipartStreaming(absolutePath, context, uploadOptions)
425
540
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bytifi",
3
- "version": "0.2.3",
3
+ "version": "0.2.5",
4
4
  "description": "Official Bytifi CLI — encrypt, upload, and decrypt files from the terminal",
5
5
  "type": "module",
6
6
  "bin": {