bytifi 0.2.4 → 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,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,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
+ })
136
+ }
137
+
138
+ if (binary) {
139
+ return responseBuffer
90
140
  }
91
141
 
92
- return payload
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) {
@@ -146,14 +249,26 @@ async function collectEncryptedBuffer(filePath, context, { onProgress, signal }
146
249
  throw new Error('Upload aborted.')
147
250
  }
148
251
 
252
+ onProgress?.({
253
+ stage: 'encrypting',
254
+ part: chunkIndex + 1,
255
+ totalParts: context.chunkCount,
256
+ percent: Math.round((chunkIndex / context.chunkCount) * 90),
257
+ })
258
+
149
259
  encryptedParts.push(await encryptChunkFromFile(fileHandle, chunkIndex, context))
150
- 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
+ })
151
266
  }
152
267
  } finally {
153
268
  await fileHandle.close()
154
269
  }
155
270
 
156
- onProgress?.(95)
271
+ onProgress?.({ stage: 'uploading', percent: 95, detail: 'direct upload' })
157
272
  return Buffer.concat(encryptedParts)
158
273
  }
159
274
 
@@ -163,6 +278,7 @@ async function uploadDirect(context, encryptedBuffer, {
163
278
  expiresInMinutes,
164
279
  deleteOnDownload,
165
280
  onProgress,
281
+ onStatus,
166
282
  signal,
167
283
  }) {
168
284
  const formData = new FormData()
@@ -179,15 +295,17 @@ async function uploadDirect(context, encryptedBuffer, {
179
295
  method: 'POST',
180
296
  body: formData,
181
297
  signal,
298
+ onStatus,
299
+ rateLimitRetries: UNLIMITED_RATE_LIMIT_RETRIES,
182
300
  })
183
301
 
184
- onProgress?.(100)
302
+ onProgress?.({ stage: 'complete', percent: 100 })
185
303
 
186
304
  const shareUrl = buildShareUrl(payload, context.token)
187
305
  return buildResult(payload, context, shareUrl)
188
306
  }
189
307
 
190
- async function pollUploadStatus(sessionToken, { apiKey, baseUrl, signal }) {
308
+ async function pollUploadStatus(sessionToken, { apiKey, baseUrl, signal, onStatus }) {
191
309
  const startedAt = Date.now()
192
310
 
193
311
  while (true) {
@@ -202,13 +320,24 @@ async function pollUploadStatus(sessionToken, { apiKey, baseUrl, signal }) {
202
320
  const payload = await apiFetchWithRetry(
203
321
  baseUrl,
204
322
  `/api/public/upload/status?sessionToken=${encodeURIComponent(sessionToken)}`,
205
- { apiKey, signal },
323
+ {
324
+ apiKey,
325
+ signal,
326
+ onStatus,
327
+ rateLimitRetries: UNLIMITED_RATE_LIMIT_RETRIES,
328
+ },
206
329
  )
207
330
 
208
331
  if (payload.status !== 'processing' && payload.status !== 'pending') {
209
332
  return payload
210
333
  }
211
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
+
212
341
  await sleep(POLL_INTERVAL_MS)
213
342
  }
214
343
  }
@@ -230,10 +359,18 @@ async function uploadMultipartStreaming(filePath, context, {
230
359
  expiresInMinutes,
231
360
  deleteOnDownload,
232
361
  onProgress,
362
+ onStatus,
233
363
  signal,
234
364
  concurrency = DEFAULT_CONCURRENCY,
235
365
  }) {
236
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
+
237
374
  const initPayload = await apiFetchWithRetry(baseUrl, '/api/public/upload/init', {
238
375
  apiKey,
239
376
  method: 'POST',
@@ -250,6 +387,8 @@ async function uploadMultipartStreaming(filePath, context, {
250
387
  deleteOnDownload,
251
388
  }),
252
389
  signal,
390
+ onStatus,
391
+ rateLimitRetries: UNLIMITED_RATE_LIMIT_RETRIES,
253
392
  })
254
393
 
255
394
  const sessionToken = initPayload.sessionToken
@@ -261,6 +400,12 @@ async function uploadMultipartStreaming(filePath, context, {
261
400
  let nextChunkIndex = 0
262
401
  let completedParts = 0
263
402
 
403
+ onProgress?.({
404
+ stage: 'uploading',
405
+ percent: 0,
406
+ detail: `${workerCount} workers · session ${sessionToken.slice(0, 8)}…`,
407
+ })
408
+
264
409
  function claimChunkIndex() {
265
410
  const chunkIndex = nextChunkIndex
266
411
  nextChunkIndex += 1
@@ -278,53 +423,87 @@ async function uploadMultipartStreaming(filePath, context, {
278
423
  return
279
424
  }
280
425
 
426
+ const partNumber = chunkIndex + 1
427
+
428
+ onProgress?.({
429
+ stage: 'encrypting',
430
+ part: partNumber,
431
+ totalParts: context.chunkCount,
432
+ percent: Math.round((completedParts / context.chunkCount) * 100),
433
+ })
434
+
281
435
  const encryptedPart = await encryptChunkFromFile(fileHandle, chunkIndex, context)
282
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
+ })
444
+
283
445
  await apiFetchWithRetry(
284
446
  baseUrl,
285
- `/api/public/upload/part?sessionToken=${encodeURIComponent(sessionToken)}&partNumber=${chunkIndex + 1}`,
447
+ `/api/public/upload/part?sessionToken=${encodeURIComponent(sessionToken)}&partNumber=${partNumber}`,
286
448
  {
287
449
  apiKey,
288
450
  method: 'PUT',
289
451
  headers: { 'Content-Type': 'application/octet-stream' },
290
452
  body: encryptedPart,
291
453
  signal,
454
+ onStatus,
455
+ rateLimitRetries: UNLIMITED_RATE_LIMIT_RETRIES,
292
456
  },
293
457
  )
294
458
 
295
459
  completedParts += 1
296
- 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
+ })
297
468
  }
298
469
  }
299
470
 
300
471
  try {
301
472
  await Promise.all(Array.from({ length: workerCount }, () => pipelineWorker()))
302
473
  } 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(() => {})
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
+ }
310
483
 
311
484
  throw error
312
485
  } finally {
313
486
  await fileHandle.close()
314
487
  }
315
488
 
489
+ onProgress?.({ stage: 'finalizing', percent: 99, detail: 'completing upload session' })
490
+
316
491
  let completePayload = await apiFetchWithRetry(baseUrl, '/api/public/upload/complete', {
317
492
  apiKey,
318
493
  method: 'POST',
319
494
  headers: { 'Content-Type': 'application/json' },
320
495
  body: JSON.stringify({ sessionToken }),
321
496
  signal,
497
+ onStatus,
498
+ rateLimitRetries: UNLIMITED_RATE_LIMIT_RETRIES,
322
499
  })
323
500
 
324
501
  if (completePayload.status === 'processing' || completePayload.status === 'pending') {
325
- completePayload = await pollUploadStatus(sessionToken, { apiKey, baseUrl, signal })
502
+ completePayload = await pollUploadStatus(sessionToken, { apiKey, baseUrl, signal, onStatus })
326
503
  }
327
504
 
505
+ onProgress?.({ stage: 'complete', percent: 100 })
506
+
328
507
  const shareUrl = buildShareUrl(completePayload, context.token)
329
508
  return buildResult(completePayload, context, shareUrl)
330
509
  }
@@ -334,18 +513,28 @@ export async function uploadFile(filePath, options) {
334
513
  throw new Error('API key is required.')
335
514
  }
336
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
+
337
526
  const { absolutePath, context } = await resolveUploadFile(filePath, {
338
527
  mimeType: options.mimeType,
339
528
  })
340
529
 
341
530
  if (context.originalSize <= MULTIPART_THRESHOLD_BYTES) {
342
531
  const encryptedBuffer = await collectEncryptedBuffer(absolutePath, context, {
343
- onProgress: options.onProgress,
532
+ onProgress: reportStatus,
344
533
  signal: options.signal,
345
534
  })
346
535
 
347
- return uploadDirect(context, encryptedBuffer, options)
536
+ return uploadDirect(context, encryptedBuffer, uploadOptions)
348
537
  }
349
538
 
350
- return uploadMultipartStreaming(absolutePath, context, options)
539
+ return uploadMultipartStreaming(absolutePath, context, uploadOptions)
351
540
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bytifi",
3
- "version": "0.2.4",
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": {