bytifi 0.2.4 → 0.2.6

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,11 @@ 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
+ }
292
296
  }
293
297
 
294
298
  function finishProgressLine() {
@@ -324,7 +328,7 @@ async function runUpload(filePath, options) {
324
328
  process.on('SIGTERM', handleSignal)
325
329
 
326
330
  const showProgress = !options.quiet && !options.json
327
- let lastPercent = -1
331
+ let lastLine = ''
328
332
 
329
333
  try {
330
334
  const result = await uploadFile(resolvedPath, {
@@ -336,10 +340,11 @@ async function runUpload(filePath, options) {
336
340
  concurrency: options.concurrency,
337
341
  signal: abortController.signal,
338
342
  onProgress: showProgress
339
- ? (percent) => {
340
- if (percent !== lastPercent) {
341
- lastPercent = percent
342
- writeProgress('Encrypting and uploading', percent)
343
+ ? (info) => {
344
+ const line = formatProgressLine(info)
345
+ if (line && line !== lastLine) {
346
+ lastLine = line
347
+ writeProgress(info)
343
348
  }
344
349
  }
345
350
  : undefined,
@@ -379,7 +384,7 @@ async function runDecrypt(input, options) {
379
384
  process.on('SIGTERM', handleSignal)
380
385
 
381
386
  const showProgress = !options.quiet && !options.json
382
- let lastPercent = -1
387
+ let lastLine = ''
383
388
 
384
389
  try {
385
390
  const result = await decryptFile(input, {
@@ -393,10 +398,11 @@ async function runDecrypt(input, options) {
393
398
  baseUrl: options.baseUrl,
394
399
  signal: abortController.signal,
395
400
  onProgress: showProgress
396
- ? (percent) => {
397
- if (percent !== lastPercent) {
398
- lastPercent = percent
399
- writeProgress('Downloading and decrypting', percent)
401
+ ? (info) => {
402
+ const line = formatProgressLine(info)
403
+ if (line && line !== lastLine) {
404
+ lastLine = line
405
+ writeProgress(info)
400
406
  }
401
407
  }
402
408
  : undefined,
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,116 @@ 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
+ maxRateLimitWaitMs = null,
157
+ onStatus,
158
+ signal,
159
+ ...fetchOptions
160
+ } = options
161
+
162
+ let attempt = 0
163
+ let rateLimitAttempt = 0
164
+ const maxRateLimitRetries = rateLimitRetries === UNLIMITED_RATE_LIMIT_RETRIES
165
+ ? UNLIMITED_RATE_LIMIT_RETRIES
166
+ : (rateLimitRetries || retries)
97
167
 
98
- for (let attempt = 0; attempt <= retries; attempt += 1) {
168
+ while (true) {
99
169
  try {
100
- return await apiFetch(baseUrl, path, options)
170
+ return await apiFetch(baseUrl, path, { ...fetchOptions, signal })
101
171
  } catch (error) {
102
- if (signal?.aborted || error.message === 'Upload aborted.') {
172
+ if (signal?.aborted || error.message === 'Upload aborted.' || error.message === 'Decrypt aborted.') {
103
173
  throw error
104
174
  }
105
175
 
106
- if (!isRetryableError(error) || attempt === retries) {
176
+ if (error instanceof BytifiApiError && error.status === 429) {
177
+ if (rateLimitAttempt >= maxRateLimitRetries) {
178
+ throw error
179
+ }
180
+
181
+ rateLimitAttempt += 1
182
+ const serverWaitMs = error.retryAfterMs ?? Math.min(600_000, 15_000 * rateLimitAttempt)
183
+ const waitMs = maxRateLimitWaitMs == null
184
+ ? serverWaitMs
185
+ : Math.min(serverWaitMs, maxRateLimitWaitMs)
186
+
187
+ onStatus?.({
188
+ stage: 'waiting',
189
+ message: maxRateLimitWaitMs != null && serverWaitMs > waitMs
190
+ ? `Rate limited — retrying in ${formatDuration(waitMs)} (part upload continues when server allows)…`
191
+ : `Rate limited — waiting ${formatDuration(waitMs)} before retry (${rateLimitAttempt})…`,
192
+ waitMs,
193
+ retryAttempt: rateLimitAttempt,
194
+ })
195
+
196
+ await sleep(waitMs)
197
+ continue
198
+ }
199
+
200
+ if (!isRetryableError(error) || attempt >= retries) {
107
201
  throw error
108
202
  }
109
203
 
110
- const delayMs = Math.min(1000 * (2 ** attempt), 8000)
111
- await sleep(delayMs)
204
+ attempt += 1
205
+ await sleep(Math.min(1000 * (2 ** attempt), 8000))
112
206
  }
113
207
  }
208
+ }
209
+
210
+ export async function publicFetchWithRetry(baseUrl, path, options = {}) {
211
+ return apiFetchWithRetry(baseUrl, path, {
212
+ ...options,
213
+ apiKey: null,
214
+ rateLimitRetries: options.rateLimitRetries ?? UNLIMITED_RATE_LIMIT_RETRIES,
215
+ })
216
+ }
114
217
 
115
- throw new Error('Request failed after retries.')
218
+ export async function fetchPublicBinaryWithRetry(baseUrl, path, options = {}) {
219
+ return apiFetchWithRetry(baseUrl, path, {
220
+ ...options,
221
+ apiKey: null,
222
+ binary: true,
223
+ rateLimitRetries: options.rateLimitRetries ?? UNLIMITED_RATE_LIMIT_RETRIES,
224
+ })
116
225
  }
117
226
 
118
227
  function buildShareUrl(payload, encryptionToken) {
@@ -146,14 +255,26 @@ async function collectEncryptedBuffer(filePath, context, { onProgress, signal }
146
255
  throw new Error('Upload aborted.')
147
256
  }
148
257
 
258
+ onProgress?.({
259
+ stage: 'encrypting',
260
+ part: chunkIndex + 1,
261
+ totalParts: context.chunkCount,
262
+ percent: Math.round((chunkIndex / context.chunkCount) * 90),
263
+ })
264
+
149
265
  encryptedParts.push(await encryptChunkFromFile(fileHandle, chunkIndex, context))
150
- onProgress?.(Math.round(((chunkIndex + 1) / context.chunkCount) * 90))
266
+ onProgress?.({
267
+ stage: 'encrypted',
268
+ part: chunkIndex + 1,
269
+ totalParts: context.chunkCount,
270
+ percent: Math.round(((chunkIndex + 1) / context.chunkCount) * 90),
271
+ })
151
272
  }
152
273
  } finally {
153
274
  await fileHandle.close()
154
275
  }
155
276
 
156
- onProgress?.(95)
277
+ onProgress?.({ stage: 'uploading', percent: 95, detail: 'direct upload' })
157
278
  return Buffer.concat(encryptedParts)
158
279
  }
159
280
 
@@ -163,6 +284,7 @@ async function uploadDirect(context, encryptedBuffer, {
163
284
  expiresInMinutes,
164
285
  deleteOnDownload,
165
286
  onProgress,
287
+ onStatus,
166
288
  signal,
167
289
  }) {
168
290
  const formData = new FormData()
@@ -179,15 +301,17 @@ async function uploadDirect(context, encryptedBuffer, {
179
301
  method: 'POST',
180
302
  body: formData,
181
303
  signal,
304
+ onStatus,
305
+ rateLimitRetries: UNLIMITED_RATE_LIMIT_RETRIES,
182
306
  })
183
307
 
184
- onProgress?.(100)
308
+ onProgress?.({ stage: 'complete', percent: 100 })
185
309
 
186
310
  const shareUrl = buildShareUrl(payload, context.token)
187
311
  return buildResult(payload, context, shareUrl)
188
312
  }
189
313
 
190
- async function pollUploadStatus(sessionToken, { apiKey, baseUrl, signal }) {
314
+ async function pollUploadStatus(sessionToken, { apiKey, baseUrl, signal, onStatus }) {
191
315
  const startedAt = Date.now()
192
316
 
193
317
  while (true) {
@@ -202,13 +326,24 @@ async function pollUploadStatus(sessionToken, { apiKey, baseUrl, signal }) {
202
326
  const payload = await apiFetchWithRetry(
203
327
  baseUrl,
204
328
  `/api/public/upload/status?sessionToken=${encodeURIComponent(sessionToken)}`,
205
- { apiKey, signal },
329
+ {
330
+ apiKey,
331
+ signal,
332
+ onStatus,
333
+ rateLimitRetries: UNLIMITED_RATE_LIMIT_RETRIES,
334
+ },
206
335
  )
207
336
 
208
337
  if (payload.status !== 'processing' && payload.status !== 'pending') {
209
338
  return payload
210
339
  }
211
340
 
341
+ onStatus?.({
342
+ stage: 'finalizing',
343
+ percent: Math.min(99, Math.round(Number(payload?.progress?.percent || 0))),
344
+ detail: payload?.progress?.phase || 'processing on server',
345
+ })
346
+
212
347
  await sleep(POLL_INTERVAL_MS)
213
348
  }
214
349
  }
@@ -230,10 +365,18 @@ async function uploadMultipartStreaming(filePath, context, {
230
365
  expiresInMinutes,
231
366
  deleteOnDownload,
232
367
  onProgress,
368
+ onStatus,
233
369
  signal,
234
370
  concurrency = DEFAULT_CONCURRENCY,
235
371
  }) {
236
372
  const maxPartSize = context.meta.chunkSize + 16
373
+
374
+ onProgress?.({
375
+ stage: 'starting',
376
+ percent: 0,
377
+ detail: `${context.chunkCount} parts · ${context.compression === 'gzip' ? 'gzip' : 'raw'} chunks`,
378
+ })
379
+
237
380
  const initPayload = await apiFetchWithRetry(baseUrl, '/api/public/upload/init', {
238
381
  apiKey,
239
382
  method: 'POST',
@@ -250,6 +393,8 @@ async function uploadMultipartStreaming(filePath, context, {
250
393
  deleteOnDownload,
251
394
  }),
252
395
  signal,
396
+ onStatus,
397
+ rateLimitRetries: UNLIMITED_RATE_LIMIT_RETRIES,
253
398
  })
254
399
 
255
400
  const sessionToken = initPayload.sessionToken
@@ -261,6 +406,12 @@ async function uploadMultipartStreaming(filePath, context, {
261
406
  let nextChunkIndex = 0
262
407
  let completedParts = 0
263
408
 
409
+ onProgress?.({
410
+ stage: 'uploading',
411
+ percent: 0,
412
+ detail: `${workerCount} workers · session ${sessionToken.slice(0, 8)}…`,
413
+ })
414
+
264
415
  function claimChunkIndex() {
265
416
  const chunkIndex = nextChunkIndex
266
417
  nextChunkIndex += 1
@@ -278,53 +429,88 @@ async function uploadMultipartStreaming(filePath, context, {
278
429
  return
279
430
  }
280
431
 
432
+ const partNumber = chunkIndex + 1
433
+
434
+ onProgress?.({
435
+ stage: 'encrypting',
436
+ part: partNumber,
437
+ totalParts: context.chunkCount,
438
+ percent: Math.round((completedParts / context.chunkCount) * 100),
439
+ })
440
+
281
441
  const encryptedPart = await encryptChunkFromFile(fileHandle, chunkIndex, context)
282
442
 
443
+ onProgress?.({
444
+ stage: 'uploading',
445
+ part: partNumber,
446
+ totalParts: context.chunkCount,
447
+ partBytes: encryptedPart.length,
448
+ percent: Math.round((completedParts / context.chunkCount) * 100),
449
+ })
450
+
283
451
  await apiFetchWithRetry(
284
452
  baseUrl,
285
- `/api/public/upload/part?sessionToken=${encodeURIComponent(sessionToken)}&partNumber=${chunkIndex + 1}`,
453
+ `/api/public/upload/part?sessionToken=${encodeURIComponent(sessionToken)}&partNumber=${partNumber}`,
286
454
  {
287
455
  apiKey,
288
456
  method: 'PUT',
289
457
  headers: { 'Content-Type': 'application/octet-stream' },
290
458
  body: encryptedPart,
291
459
  signal,
460
+ onStatus,
461
+ rateLimitRetries: UNLIMITED_RATE_LIMIT_RETRIES,
462
+ maxRateLimitWaitMs: 15_000,
292
463
  },
293
464
  )
294
465
 
295
466
  completedParts += 1
296
- onProgress?.(Math.round((completedParts / context.chunkCount) * 100))
467
+ onProgress?.({
468
+ stage: 'uploaded',
469
+ part: partNumber,
470
+ totalParts: context.chunkCount,
471
+ partBytes: encryptedPart.length,
472
+ percent: Math.round((completedParts / context.chunkCount) * 100),
473
+ detail: `${completedParts}/${context.chunkCount} parts done`,
474
+ })
297
475
  }
298
476
  }
299
477
 
300
478
  try {
301
479
  await Promise.all(Array.from({ length: workerCount }, () => pipelineWorker()))
302
480
  } catch (error) {
303
- await apiFetchWithRetry(baseUrl, '/api/public/upload/abort', {
304
- apiKey,
305
- method: 'POST',
306
- headers: { 'Content-Type': 'application/json' },
307
- body: JSON.stringify({ sessionToken }),
308
- signal,
309
- }).catch(() => {})
481
+ if (!(error instanceof BytifiApiError && error.status === 429)) {
482
+ await apiFetchWithRetry(baseUrl, '/api/public/upload/abort', {
483
+ apiKey,
484
+ method: 'POST',
485
+ headers: { 'Content-Type': 'application/json' },
486
+ body: JSON.stringify({ sessionToken }),
487
+ signal,
488
+ }).catch(() => {})
489
+ }
310
490
 
311
491
  throw error
312
492
  } finally {
313
493
  await fileHandle.close()
314
494
  }
315
495
 
496
+ onProgress?.({ stage: 'finalizing', percent: 99, detail: 'completing upload session' })
497
+
316
498
  let completePayload = await apiFetchWithRetry(baseUrl, '/api/public/upload/complete', {
317
499
  apiKey,
318
500
  method: 'POST',
319
501
  headers: { 'Content-Type': 'application/json' },
320
502
  body: JSON.stringify({ sessionToken }),
321
503
  signal,
504
+ onStatus,
505
+ rateLimitRetries: UNLIMITED_RATE_LIMIT_RETRIES,
322
506
  })
323
507
 
324
508
  if (completePayload.status === 'processing' || completePayload.status === 'pending') {
325
- completePayload = await pollUploadStatus(sessionToken, { apiKey, baseUrl, signal })
509
+ completePayload = await pollUploadStatus(sessionToken, { apiKey, baseUrl, signal, onStatus })
326
510
  }
327
511
 
512
+ onProgress?.({ stage: 'complete', percent: 100 })
513
+
328
514
  const shareUrl = buildShareUrl(completePayload, context.token)
329
515
  return buildResult(completePayload, context, shareUrl)
330
516
  }
@@ -334,18 +520,28 @@ export async function uploadFile(filePath, options) {
334
520
  throw new Error('API key is required.')
335
521
  }
336
522
 
523
+ const reportStatus = (info) => {
524
+ options.onStatus?.(info)
525
+ options.onProgress?.(info)
526
+ }
527
+
528
+ const uploadOptions = {
529
+ ...options,
530
+ onStatus: reportStatus,
531
+ }
532
+
337
533
  const { absolutePath, context } = await resolveUploadFile(filePath, {
338
534
  mimeType: options.mimeType,
339
535
  })
340
536
 
341
537
  if (context.originalSize <= MULTIPART_THRESHOLD_BYTES) {
342
538
  const encryptedBuffer = await collectEncryptedBuffer(absolutePath, context, {
343
- onProgress: options.onProgress,
539
+ onProgress: reportStatus,
344
540
  signal: options.signal,
345
541
  })
346
542
 
347
- return uploadDirect(context, encryptedBuffer, options)
543
+ return uploadDirect(context, encryptedBuffer, uploadOptions)
348
544
  }
349
545
 
350
- return uploadMultipartStreaming(absolutePath, context, options)
546
+ return uploadMultipartStreaming(absolutePath, context, uploadOptions)
351
547
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bytifi",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Official Bytifi CLI — encrypt, upload, and decrypt files from the terminal",
5
5
  "type": "module",
6
6
  "bin": {