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 +18 -12
- package/lib/decrypt.js +59 -63
- package/lib/progress.js +67 -0
- package/lib/upload.js +221 -32
- package/package.json +1 -1
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(
|
|
291
|
-
|
|
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
|
|
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
|
-
? (
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
|
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
|
-
? (
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
216
|
-
|
|
217
|
-
{
|
|
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
|
|
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
|
-
|
|
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,
|
|
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:
|
|
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:
|
|
826
|
+
onProgress: reportStatus,
|
|
827
|
+
onStatus: reportStatus,
|
|
832
828
|
signal: resolvedOptions.signal,
|
|
833
829
|
})
|
|
834
830
|
}
|
package/lib/progress.js
ADDED
|
@@ -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
|
|
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, {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
167
|
+
while (true) {
|
|
99
168
|
try {
|
|
100
|
-
return await apiFetch(baseUrl, path,
|
|
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 (
|
|
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
|
-
|
|
111
|
-
await sleep(
|
|
198
|
+
attempt += 1
|
|
199
|
+
await sleep(Math.min(1000 * (2 ** attempt), 8000))
|
|
112
200
|
}
|
|
113
201
|
}
|
|
202
|
+
}
|
|
114
203
|
|
|
115
|
-
|
|
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?.(
|
|
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
|
-
{
|
|
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=${
|
|
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?.(
|
|
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
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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:
|
|
532
|
+
onProgress: reportStatus,
|
|
344
533
|
signal: options.signal,
|
|
345
534
|
})
|
|
346
535
|
|
|
347
|
-
return uploadDirect(context, encryptedBuffer,
|
|
536
|
+
return uploadDirect(context, encryptedBuffer, uploadOptions)
|
|
348
537
|
}
|
|
349
538
|
|
|
350
|
-
return uploadMultipartStreaming(absolutePath, context,
|
|
539
|
+
return uploadMultipartStreaming(absolutePath, context, uploadOptions)
|
|
351
540
|
}
|