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/LICENSE +21 -0
- package/README.md +19 -5
- package/bin/bytifi.js +168 -66
- package/lib/api.js +203 -0
- package/lib/base64url.js +10 -2
- package/lib/crypto.js +0 -1
- package/lib/decrypt.js +184 -136
- package/lib/progress.js +50 -0
- package/lib/upload.js +94 -254
- package/lib/url.js +26 -0
- package/package.json +5 -2
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
|
|
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 (
|
|
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
|
-
|
|
429
|
-
|
|
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
|
-
|
|
273
|
+
const encryptedPart = await encryptChunkFromFile(fileHandle, chunkIndex, context)
|
|
436
274
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
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.
|
|
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",
|