bytifi 0.2.5 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/upload.js CHANGED
@@ -1,222 +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
- 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)
166
-
167
- while (true) {
168
- try {
169
- return await apiFetch(baseUrl, path, { ...fetchOptions, signal })
170
- } catch (error) {
171
- if (signal?.aborted || error.message === 'Upload aborted.' || error.message === 'Decrypt aborted.') {
172
- throw error
173
- }
174
-
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) {
195
- throw error
196
- }
197
-
198
- attempt += 1
199
- await sleep(Math.min(1000 * (2 ** attempt), 8000))
200
- }
201
- }
202
- }
203
-
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
- })
219
- }
17
+ export { BytifiApiError, BytifiNetworkError, fetchPublicBinaryWithRetry, publicFetchWithRetry } from './api.js'
220
18
 
221
19
  function buildShareUrl(payload, encryptionToken) {
222
20
  return `${payload.url}#token=${encodeURIComponent(encryptionToken)}`
@@ -239,6 +37,11 @@ function buildResult(payload, context, shareUrl) {
239
37
  }
240
38
  }
241
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
+
242
45
  async function collectEncryptedBuffer(filePath, context, { onProgress, signal } = {}) {
243
46
  const fileHandle = await fs.open(filePath, 'r')
244
47
  const encryptedParts = []
@@ -257,6 +60,11 @@ async function collectEncryptedBuffer(filePath, context, { onProgress, signal }
257
60
  })
258
61
 
259
62
  encryptedParts.push(await encryptChunkFromFile(fileHandle, chunkIndex, context))
63
+
64
+ if (signal?.aborted) {
65
+ throw new Error('Upload aborted.')
66
+ }
67
+
260
68
  onProgress?.({
261
69
  stage: 'encrypted',
262
70
  part: chunkIndex + 1,
@@ -353,6 +161,17 @@ function resolveUploadConcurrency(originalSize, requested) {
353
161
  return DEFAULT_CONCURRENCY
354
162
  }
355
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
+
356
175
  async function uploadMultipartStreaming(filePath, context, {
357
176
  apiKey,
358
177
  baseUrl,
@@ -397,8 +216,20 @@ async function uploadMultipartStreaming(filePath, context, {
397
216
  context.chunkCount,
398
217
  )
399
218
  const fileHandle = await fs.open(filePath, 'r')
219
+ const workerAbort = new AbortController()
400
220
  let nextChunkIndex = 0
401
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
+ })()
402
233
 
403
234
  onProgress?.({
404
235
  stage: 'uploading',
@@ -406,6 +237,16 @@ async function uploadMultipartStreaming(filePath, context, {
406
237
  detail: `${workerCount} workers · session ${sessionToken.slice(0, 8)}…`,
407
238
  })
408
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
+
409
250
  function claimChunkIndex() {
410
251
  const chunkIndex = nextChunkIndex
411
252
  nextChunkIndex += 1
@@ -414,7 +255,7 @@ async function uploadMultipartStreaming(filePath, context, {
414
255
 
415
256
  async function pipelineWorker() {
416
257
  while (true) {
417
- if (signal?.aborted) {
258
+ if (combinedSignal.aborted) {
418
259
  throw new Error('Upload aborted.')
419
260
  }
420
261
 
@@ -424,61 +265,60 @@ async function uploadMultipartStreaming(filePath, context, {
424
265
  }
425
266
 
426
267
  const partNumber = chunkIndex + 1
268
+ inFlightParts += 1
427
269
 
428
- onProgress?.({
429
- stage: 'encrypting',
430
- part: partNumber,
431
- totalParts: context.chunkCount,
432
- percent: Math.round((completedParts / context.chunkCount) * 100),
433
- })
270
+ try {
271
+ reportPartProgress('encrypting', partNumber)
434
272
 
435
- const encryptedPart = await encryptChunkFromFile(fileHandle, chunkIndex, context)
273
+ const encryptedPart = await encryptChunkFromFile(fileHandle, chunkIndex, context)
436
274
 
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
- })
275
+ if (combinedSignal.aborted) {
276
+ throw new Error('Upload aborted.')
277
+ }
444
278
 
445
- await apiFetchWithRetry(
446
- baseUrl,
447
- `/api/public/upload/part?sessionToken=${encodeURIComponent(sessionToken)}&partNumber=${partNumber}`,
448
- {
449
- apiKey,
450
- method: 'PUT',
451
- headers: { 'Content-Type': 'application/octet-stream' },
452
- body: encryptedPart,
453
- signal,
454
- onStatus,
455
- rateLimitRetries: UNLIMITED_RATE_LIMIT_RETRIES,
456
- },
457
- )
458
-
459
- completedParts += 1
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
- })
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
+ }
468
311
  }
469
312
  }
470
313
 
471
314
  try {
472
315
  await Promise.all(Array.from({ length: workerCount }, () => pipelineWorker()))
473
316
  } catch (error) {
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(() => {})
317
+ workerAbort.abort()
318
+
319
+ if (!sessionAborted) {
320
+ sessionAborted = true
321
+ await abortUploadSession(baseUrl, sessionToken, { apiKey, signal })
482
322
  }
483
323
 
484
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.5",
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",