bytifi 0.2.6 → 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/upload.js CHANGED
@@ -1,228 +1,20 @@
1
+ import fs from 'node:fs/promises'
2
+ import {
3
+ apiFetchWithRetry,
4
+ sleep,
5
+ } from './api.js'
1
6
  import {
2
7
  MULTIPART_THRESHOLD_BYTES,
3
8
  encryptChunkFromFile,
4
9
  resolveUploadFile,
5
10
  } from './crypto.js'
6
- import { formatDuration } from './progress.js'
7
- import fs from 'node:fs/promises'
8
11
 
9
- const DEFAULT_BASE_URL = 'https://bytifi.com'
10
12
  const DEFAULT_CONCURRENCY = 4
11
13
  const POLL_INTERVAL_MS = 1500
12
14
  const POLL_TIMEOUT_MS = 30 * 60 * 1000
13
- const MAX_RETRIES = 3
14
15
  const UNLIMITED_RATE_LIMIT_RETRIES = Number.POSITIVE_INFINITY
15
16
 
16
- export class BytifiApiError extends Error {
17
- constructor(message, { status = 0, body = null, retryAfterMs = null } = {}) {
18
- super(message)
19
- this.name = 'BytifiApiError'
20
- this.status = status
21
- this.body = body
22
- this.retryAfterMs = retryAfterMs
23
- }
24
- }
25
-
26
- export class BytifiNetworkError extends Error {
27
- constructor(message, { cause } = {}) {
28
- super(message)
29
- this.name = 'BytifiNetworkError'
30
- this.cause = cause
31
- }
32
- }
33
-
34
- function sleep(ms) {
35
- return new Promise((resolve) => setTimeout(resolve, ms))
36
- }
37
-
38
- function normalizeBaseUrl(baseUrl) {
39
- return String(baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '')
40
- }
41
-
42
- function isRetryableError(error) {
43
- if (error instanceof BytifiNetworkError) return true
44
- if (error instanceof BytifiApiError) {
45
- return error.status === 429 || error.status >= 500
46
- }
47
- return false
48
- }
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
-
78
- async function readResponseBody(response) {
79
- const text = await response.text()
80
- if (!text) return null
81
-
82
- try {
83
- return JSON.parse(text)
84
- } catch {
85
- return text
86
- }
87
- }
88
-
89
- async function apiFetch(baseUrl, path, { apiKey, method = 'GET', headers = {}, body = null, signal, binary = false } = {}) {
90
- const url = `${normalizeBaseUrl(baseUrl)}${path}`
91
- const requestHeaders = {
92
- ...headers,
93
- }
94
-
95
- if (apiKey) {
96
- requestHeaders.Authorization = `Bearer ${apiKey}`
97
- }
98
-
99
- let response
100
-
101
- try {
102
- response = await fetch(url, {
103
- method,
104
- headers: requestHeaders,
105
- body,
106
- signal,
107
- })
108
- } catch (error) {
109
- if (signal?.aborted) {
110
- throw new Error('Upload aborted.')
111
- }
112
- throw new BytifiNetworkError(error.message || 'Network request failed.', { cause: error })
113
- }
114
-
115
- const responseBuffer = Buffer.from(await response.arrayBuffer())
116
-
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
-
126
- const message = typeof payload === 'object' && payload?.error
127
- ? payload.error
128
- : typeof payload === 'string' && payload
129
- ? payload
130
- : `Request failed with status ${response.status}.`
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
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
- }
150
- }
151
-
152
- async function apiFetchWithRetry(baseUrl, path, 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)
167
-
168
- while (true) {
169
- try {
170
- return await apiFetch(baseUrl, path, { ...fetchOptions, signal })
171
- } catch (error) {
172
- if (signal?.aborted || error.message === 'Upload aborted.' || error.message === 'Decrypt aborted.') {
173
- throw error
174
- }
175
-
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) {
201
- throw error
202
- }
203
-
204
- attempt += 1
205
- await sleep(Math.min(1000 * (2 ** attempt), 8000))
206
- }
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
- }
217
-
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
- })
225
- }
17
+ export { BytifiApiError, BytifiNetworkError, fetchPublicBinaryWithRetry, publicFetchWithRetry } from './api.js'
226
18
 
227
19
  function buildShareUrl(payload, encryptionToken) {
228
20
  return `${payload.url}#token=${encodeURIComponent(encryptionToken)}`
@@ -245,6 +37,11 @@ function buildResult(payload, context, shareUrl) {
245
37
  }
246
38
  }
247
39
 
40
+ function uploadProgressPercent(completedParts, inFlightParts, totalParts) {
41
+ const weighted = completedParts + inFlightParts * 0.5
42
+ return Math.min(100, Math.round((weighted / totalParts) * 100))
43
+ }
44
+
248
45
  async function collectEncryptedBuffer(filePath, context, { onProgress, signal } = {}) {
249
46
  const fileHandle = await fs.open(filePath, 'r')
250
47
  const encryptedParts = []
@@ -263,6 +60,11 @@ async function collectEncryptedBuffer(filePath, context, { onProgress, signal }
263
60
  })
264
61
 
265
62
  encryptedParts.push(await encryptChunkFromFile(fileHandle, chunkIndex, context))
63
+
64
+ if (signal?.aborted) {
65
+ throw new Error('Upload aborted.')
66
+ }
67
+
266
68
  onProgress?.({
267
69
  stage: 'encrypted',
268
70
  part: chunkIndex + 1,
@@ -359,6 +161,17 @@ function resolveUploadConcurrency(originalSize, requested) {
359
161
  return DEFAULT_CONCURRENCY
360
162
  }
361
163
 
164
+ async function abortUploadSession(baseUrl, sessionToken, { apiKey, signal }) {
165
+ await apiFetchWithRetry(baseUrl, '/api/public/upload/abort', {
166
+ apiKey,
167
+ method: 'POST',
168
+ headers: { 'Content-Type': 'application/json' },
169
+ body: JSON.stringify({ sessionToken }),
170
+ signal,
171
+ retries: 1,
172
+ }).catch(() => {})
173
+ }
174
+
362
175
  async function uploadMultipartStreaming(filePath, context, {
363
176
  apiKey,
364
177
  baseUrl,
@@ -403,8 +216,20 @@ async function uploadMultipartStreaming(filePath, context, {
403
216
  context.chunkCount,
404
217
  )
405
218
  const fileHandle = await fs.open(filePath, 'r')
219
+ const workerAbort = new AbortController()
406
220
  let nextChunkIndex = 0
407
221
  let completedParts = 0
222
+ let inFlightParts = 0
223
+ let sessionAborted = false
224
+
225
+ const combinedSignal = AbortSignal.any?.([signal, workerAbort.signal])
226
+ ?? (() => {
227
+ const controller = new AbortController()
228
+ const abort = () => controller.abort()
229
+ signal?.addEventListener('abort', abort, { once: true })
230
+ workerAbort.signal.addEventListener('abort', abort, { once: true })
231
+ return controller.signal
232
+ })()
408
233
 
409
234
  onProgress?.({
410
235
  stage: 'uploading',
@@ -412,6 +237,16 @@ async function uploadMultipartStreaming(filePath, context, {
412
237
  detail: `${workerCount} workers · session ${sessionToken.slice(0, 8)}…`,
413
238
  })
414
239
 
240
+ function reportPartProgress(stage, partNumber, partBytes) {
241
+ onProgress?.({
242
+ stage,
243
+ part: partNumber,
244
+ totalParts: context.chunkCount,
245
+ partBytes,
246
+ percent: uploadProgressPercent(completedParts, inFlightParts, context.chunkCount),
247
+ })
248
+ }
249
+
415
250
  function claimChunkIndex() {
416
251
  const chunkIndex = nextChunkIndex
417
252
  nextChunkIndex += 1
@@ -420,7 +255,7 @@ async function uploadMultipartStreaming(filePath, context, {
420
255
 
421
256
  async function pipelineWorker() {
422
257
  while (true) {
423
- if (signal?.aborted) {
258
+ if (combinedSignal.aborted) {
424
259
  throw new Error('Upload aborted.')
425
260
  }
426
261
 
@@ -430,62 +265,60 @@ async function uploadMultipartStreaming(filePath, context, {
430
265
  }
431
266
 
432
267
  const partNumber = chunkIndex + 1
268
+ inFlightParts += 1
433
269
 
434
- onProgress?.({
435
- stage: 'encrypting',
436
- part: partNumber,
437
- totalParts: context.chunkCount,
438
- percent: Math.round((completedParts / context.chunkCount) * 100),
439
- })
270
+ try {
271
+ reportPartProgress('encrypting', partNumber)
440
272
 
441
- const encryptedPart = await encryptChunkFromFile(fileHandle, chunkIndex, context)
273
+ const encryptedPart = await encryptChunkFromFile(fileHandle, chunkIndex, context)
442
274
 
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
- })
275
+ if (combinedSignal.aborted) {
276
+ throw new Error('Upload aborted.')
277
+ }
450
278
 
451
- await apiFetchWithRetry(
452
- baseUrl,
453
- `/api/public/upload/part?sessionToken=${encodeURIComponent(sessionToken)}&partNumber=${partNumber}`,
454
- {
455
- apiKey,
456
- method: 'PUT',
457
- headers: { 'Content-Type': 'application/octet-stream' },
458
- body: encryptedPart,
459
- signal,
460
- onStatus,
461
- rateLimitRetries: UNLIMITED_RATE_LIMIT_RETRIES,
462
- maxRateLimitWaitMs: 15_000,
463
- },
464
- )
465
-
466
- completedParts += 1
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
- })
279
+ reportPartProgress('uploading', partNumber, encryptedPart.length)
280
+
281
+ await apiFetchWithRetry(
282
+ baseUrl,
283
+ `/api/public/upload/part?sessionToken=${encodeURIComponent(sessionToken)}&partNumber=${partNumber}`,
284
+ {
285
+ apiKey,
286
+ method: 'PUT',
287
+ headers: {
288
+ 'Content-Type': 'application/octet-stream',
289
+ 'X-Upload-Session': sessionToken,
290
+ },
291
+ body: encryptedPart,
292
+ signal: combinedSignal,
293
+ onStatus,
294
+ rateLimitRetries: UNLIMITED_RATE_LIMIT_RETRIES,
295
+ maxRateLimitWaitMs: 15_000,
296
+ },
297
+ )
298
+
299
+ completedParts += 1
300
+ onProgress?.({
301
+ stage: 'uploaded',
302
+ part: partNumber,
303
+ totalParts: context.chunkCount,
304
+ partBytes: encryptedPart.length,
305
+ percent: uploadProgressPercent(completedParts, inFlightParts, context.chunkCount),
306
+ detail: `${completedParts}/${context.chunkCount} parts done`,
307
+ })
308
+ } finally {
309
+ inFlightParts -= 1
310
+ }
475
311
  }
476
312
  }
477
313
 
478
314
  try {
479
315
  await Promise.all(Array.from({ length: workerCount }, () => pipelineWorker()))
480
316
  } catch (error) {
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(() => {})
317
+ workerAbort.abort()
318
+
319
+ if (!sessionAborted) {
320
+ sessionAborted = true
321
+ await abortUploadSession(baseUrl, sessionToken, { apiKey, signal })
489
322
  }
490
323
 
491
324
  throw error
package/lib/url.js ADDED
@@ -0,0 +1,26 @@
1
+ export const DEFAULT_BASE_URL = 'https://bytifi.com'
2
+
3
+ export function normalizeBaseUrl(baseUrl) {
4
+ return String(baseUrl || DEFAULT_BASE_URL).replace(/\/+$/, '')
5
+ }
6
+
7
+ export function validateBaseUrl(url) {
8
+ const normalized = normalizeBaseUrl(url)
9
+
10
+ let parsed
11
+ try {
12
+ parsed = new URL(normalized)
13
+ } catch {
14
+ throw new Error(`Invalid base URL: ${url}`)
15
+ }
16
+
17
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
18
+ throw new Error(`Invalid base URL protocol: ${parsed.protocol} (use http: or https:)`)
19
+ }
20
+
21
+ if (!parsed.hostname) {
22
+ throw new Error('Invalid base URL: missing hostname.')
23
+ }
24
+
25
+ return normalized
26
+ }
package/package.json CHANGED
@@ -1,8 +1,9 @@
1
1
  {
2
2
  "name": "bytifi",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "Official Bytifi CLI — encrypt, upload, and decrypt files from the terminal",
5
5
  "type": "module",
6
+ "author": "Bytifi",
6
7
  "bin": {
7
8
  "bytifi": "./bin/bytifi.js"
8
9
  },
@@ -14,10 +15,12 @@
14
15
  "node": ">=18"
15
16
  },
16
17
  "scripts": {
18
+ "test": "node --test test/*.test.js",
17
19
  "upload": "node bin/bytifi.js upload",
18
20
  "decrypt": "node bin/bytifi.js decrypt",
19
21
  "build:bundle": "node scripts/build-bundle.mjs",
20
- "build:win": "npm run build:bundle && pkg dist/bytifi-bundle.cjs --targets node22-win-x64 --output dist/bytifi.exe --compress GZip --public --public-packages \"*\""
22
+ "build:win": "npm run build:bundle && pkg dist/bytifi-bundle.cjs --targets node22-win-x64 --output dist/bytifi.exe --compress GZip --public --public-packages \"*\"",
23
+ "prepublishOnly": "npm test"
21
24
  },
22
25
  "repository": {
23
26
  "type": "git",