bytifi 0.2.4 → 0.2.6
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 +228 -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,116 @@ 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
|
+
})
|
|
90
136
|
}
|
|
91
137
|
|
|
92
|
-
|
|
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
|
+
}
|
|
93
150
|
}
|
|
94
151
|
|
|
95
152
|
async function apiFetchWithRetry(baseUrl, path, options = {}) {
|
|
96
|
-
const {
|
|
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)
|
|
97
167
|
|
|
98
|
-
|
|
168
|
+
while (true) {
|
|
99
169
|
try {
|
|
100
|
-
return await apiFetch(baseUrl, path,
|
|
170
|
+
return await apiFetch(baseUrl, path, { ...fetchOptions, signal })
|
|
101
171
|
} catch (error) {
|
|
102
|
-
if (signal?.aborted || error.message === 'Upload aborted.') {
|
|
172
|
+
if (signal?.aborted || error.message === 'Upload aborted.' || error.message === 'Decrypt aborted.') {
|
|
103
173
|
throw error
|
|
104
174
|
}
|
|
105
175
|
|
|
106
|
-
if (
|
|
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) {
|
|
107
201
|
throw error
|
|
108
202
|
}
|
|
109
203
|
|
|
110
|
-
|
|
111
|
-
await sleep(
|
|
204
|
+
attempt += 1
|
|
205
|
+
await sleep(Math.min(1000 * (2 ** attempt), 8000))
|
|
112
206
|
}
|
|
113
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
|
+
}
|
|
114
217
|
|
|
115
|
-
|
|
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
|
+
})
|
|
116
225
|
}
|
|
117
226
|
|
|
118
227
|
function buildShareUrl(payload, encryptionToken) {
|
|
@@ -146,14 +255,26 @@ async function collectEncryptedBuffer(filePath, context, { onProgress, signal }
|
|
|
146
255
|
throw new Error('Upload aborted.')
|
|
147
256
|
}
|
|
148
257
|
|
|
258
|
+
onProgress?.({
|
|
259
|
+
stage: 'encrypting',
|
|
260
|
+
part: chunkIndex + 1,
|
|
261
|
+
totalParts: context.chunkCount,
|
|
262
|
+
percent: Math.round((chunkIndex / context.chunkCount) * 90),
|
|
263
|
+
})
|
|
264
|
+
|
|
149
265
|
encryptedParts.push(await encryptChunkFromFile(fileHandle, chunkIndex, context))
|
|
150
|
-
onProgress?.(
|
|
266
|
+
onProgress?.({
|
|
267
|
+
stage: 'encrypted',
|
|
268
|
+
part: chunkIndex + 1,
|
|
269
|
+
totalParts: context.chunkCount,
|
|
270
|
+
percent: Math.round(((chunkIndex + 1) / context.chunkCount) * 90),
|
|
271
|
+
})
|
|
151
272
|
}
|
|
152
273
|
} finally {
|
|
153
274
|
await fileHandle.close()
|
|
154
275
|
}
|
|
155
276
|
|
|
156
|
-
onProgress?.(95)
|
|
277
|
+
onProgress?.({ stage: 'uploading', percent: 95, detail: 'direct upload' })
|
|
157
278
|
return Buffer.concat(encryptedParts)
|
|
158
279
|
}
|
|
159
280
|
|
|
@@ -163,6 +284,7 @@ async function uploadDirect(context, encryptedBuffer, {
|
|
|
163
284
|
expiresInMinutes,
|
|
164
285
|
deleteOnDownload,
|
|
165
286
|
onProgress,
|
|
287
|
+
onStatus,
|
|
166
288
|
signal,
|
|
167
289
|
}) {
|
|
168
290
|
const formData = new FormData()
|
|
@@ -179,15 +301,17 @@ async function uploadDirect(context, encryptedBuffer, {
|
|
|
179
301
|
method: 'POST',
|
|
180
302
|
body: formData,
|
|
181
303
|
signal,
|
|
304
|
+
onStatus,
|
|
305
|
+
rateLimitRetries: UNLIMITED_RATE_LIMIT_RETRIES,
|
|
182
306
|
})
|
|
183
307
|
|
|
184
|
-
onProgress?.(100)
|
|
308
|
+
onProgress?.({ stage: 'complete', percent: 100 })
|
|
185
309
|
|
|
186
310
|
const shareUrl = buildShareUrl(payload, context.token)
|
|
187
311
|
return buildResult(payload, context, shareUrl)
|
|
188
312
|
}
|
|
189
313
|
|
|
190
|
-
async function pollUploadStatus(sessionToken, { apiKey, baseUrl, signal }) {
|
|
314
|
+
async function pollUploadStatus(sessionToken, { apiKey, baseUrl, signal, onStatus }) {
|
|
191
315
|
const startedAt = Date.now()
|
|
192
316
|
|
|
193
317
|
while (true) {
|
|
@@ -202,13 +326,24 @@ async function pollUploadStatus(sessionToken, { apiKey, baseUrl, signal }) {
|
|
|
202
326
|
const payload = await apiFetchWithRetry(
|
|
203
327
|
baseUrl,
|
|
204
328
|
`/api/public/upload/status?sessionToken=${encodeURIComponent(sessionToken)}`,
|
|
205
|
-
{
|
|
329
|
+
{
|
|
330
|
+
apiKey,
|
|
331
|
+
signal,
|
|
332
|
+
onStatus,
|
|
333
|
+
rateLimitRetries: UNLIMITED_RATE_LIMIT_RETRIES,
|
|
334
|
+
},
|
|
206
335
|
)
|
|
207
336
|
|
|
208
337
|
if (payload.status !== 'processing' && payload.status !== 'pending') {
|
|
209
338
|
return payload
|
|
210
339
|
}
|
|
211
340
|
|
|
341
|
+
onStatus?.({
|
|
342
|
+
stage: 'finalizing',
|
|
343
|
+
percent: Math.min(99, Math.round(Number(payload?.progress?.percent || 0))),
|
|
344
|
+
detail: payload?.progress?.phase || 'processing on server',
|
|
345
|
+
})
|
|
346
|
+
|
|
212
347
|
await sleep(POLL_INTERVAL_MS)
|
|
213
348
|
}
|
|
214
349
|
}
|
|
@@ -230,10 +365,18 @@ async function uploadMultipartStreaming(filePath, context, {
|
|
|
230
365
|
expiresInMinutes,
|
|
231
366
|
deleteOnDownload,
|
|
232
367
|
onProgress,
|
|
368
|
+
onStatus,
|
|
233
369
|
signal,
|
|
234
370
|
concurrency = DEFAULT_CONCURRENCY,
|
|
235
371
|
}) {
|
|
236
372
|
const maxPartSize = context.meta.chunkSize + 16
|
|
373
|
+
|
|
374
|
+
onProgress?.({
|
|
375
|
+
stage: 'starting',
|
|
376
|
+
percent: 0,
|
|
377
|
+
detail: `${context.chunkCount} parts · ${context.compression === 'gzip' ? 'gzip' : 'raw'} chunks`,
|
|
378
|
+
})
|
|
379
|
+
|
|
237
380
|
const initPayload = await apiFetchWithRetry(baseUrl, '/api/public/upload/init', {
|
|
238
381
|
apiKey,
|
|
239
382
|
method: 'POST',
|
|
@@ -250,6 +393,8 @@ async function uploadMultipartStreaming(filePath, context, {
|
|
|
250
393
|
deleteOnDownload,
|
|
251
394
|
}),
|
|
252
395
|
signal,
|
|
396
|
+
onStatus,
|
|
397
|
+
rateLimitRetries: UNLIMITED_RATE_LIMIT_RETRIES,
|
|
253
398
|
})
|
|
254
399
|
|
|
255
400
|
const sessionToken = initPayload.sessionToken
|
|
@@ -261,6 +406,12 @@ async function uploadMultipartStreaming(filePath, context, {
|
|
|
261
406
|
let nextChunkIndex = 0
|
|
262
407
|
let completedParts = 0
|
|
263
408
|
|
|
409
|
+
onProgress?.({
|
|
410
|
+
stage: 'uploading',
|
|
411
|
+
percent: 0,
|
|
412
|
+
detail: `${workerCount} workers · session ${sessionToken.slice(0, 8)}…`,
|
|
413
|
+
})
|
|
414
|
+
|
|
264
415
|
function claimChunkIndex() {
|
|
265
416
|
const chunkIndex = nextChunkIndex
|
|
266
417
|
nextChunkIndex += 1
|
|
@@ -278,53 +429,88 @@ async function uploadMultipartStreaming(filePath, context, {
|
|
|
278
429
|
return
|
|
279
430
|
}
|
|
280
431
|
|
|
432
|
+
const partNumber = chunkIndex + 1
|
|
433
|
+
|
|
434
|
+
onProgress?.({
|
|
435
|
+
stage: 'encrypting',
|
|
436
|
+
part: partNumber,
|
|
437
|
+
totalParts: context.chunkCount,
|
|
438
|
+
percent: Math.round((completedParts / context.chunkCount) * 100),
|
|
439
|
+
})
|
|
440
|
+
|
|
281
441
|
const encryptedPart = await encryptChunkFromFile(fileHandle, chunkIndex, context)
|
|
282
442
|
|
|
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
|
+
})
|
|
450
|
+
|
|
283
451
|
await apiFetchWithRetry(
|
|
284
452
|
baseUrl,
|
|
285
|
-
`/api/public/upload/part?sessionToken=${encodeURIComponent(sessionToken)}&partNumber=${
|
|
453
|
+
`/api/public/upload/part?sessionToken=${encodeURIComponent(sessionToken)}&partNumber=${partNumber}`,
|
|
286
454
|
{
|
|
287
455
|
apiKey,
|
|
288
456
|
method: 'PUT',
|
|
289
457
|
headers: { 'Content-Type': 'application/octet-stream' },
|
|
290
458
|
body: encryptedPart,
|
|
291
459
|
signal,
|
|
460
|
+
onStatus,
|
|
461
|
+
rateLimitRetries: UNLIMITED_RATE_LIMIT_RETRIES,
|
|
462
|
+
maxRateLimitWaitMs: 15_000,
|
|
292
463
|
},
|
|
293
464
|
)
|
|
294
465
|
|
|
295
466
|
completedParts += 1
|
|
296
|
-
onProgress?.(
|
|
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
|
+
})
|
|
297
475
|
}
|
|
298
476
|
}
|
|
299
477
|
|
|
300
478
|
try {
|
|
301
479
|
await Promise.all(Array.from({ length: workerCount }, () => pipelineWorker()))
|
|
302
480
|
} catch (error) {
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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(() => {})
|
|
489
|
+
}
|
|
310
490
|
|
|
311
491
|
throw error
|
|
312
492
|
} finally {
|
|
313
493
|
await fileHandle.close()
|
|
314
494
|
}
|
|
315
495
|
|
|
496
|
+
onProgress?.({ stage: 'finalizing', percent: 99, detail: 'completing upload session' })
|
|
497
|
+
|
|
316
498
|
let completePayload = await apiFetchWithRetry(baseUrl, '/api/public/upload/complete', {
|
|
317
499
|
apiKey,
|
|
318
500
|
method: 'POST',
|
|
319
501
|
headers: { 'Content-Type': 'application/json' },
|
|
320
502
|
body: JSON.stringify({ sessionToken }),
|
|
321
503
|
signal,
|
|
504
|
+
onStatus,
|
|
505
|
+
rateLimitRetries: UNLIMITED_RATE_LIMIT_RETRIES,
|
|
322
506
|
})
|
|
323
507
|
|
|
324
508
|
if (completePayload.status === 'processing' || completePayload.status === 'pending') {
|
|
325
|
-
completePayload = await pollUploadStatus(sessionToken, { apiKey, baseUrl, signal })
|
|
509
|
+
completePayload = await pollUploadStatus(sessionToken, { apiKey, baseUrl, signal, onStatus })
|
|
326
510
|
}
|
|
327
511
|
|
|
512
|
+
onProgress?.({ stage: 'complete', percent: 100 })
|
|
513
|
+
|
|
328
514
|
const shareUrl = buildShareUrl(completePayload, context.token)
|
|
329
515
|
return buildResult(completePayload, context, shareUrl)
|
|
330
516
|
}
|
|
@@ -334,18 +520,28 @@ export async function uploadFile(filePath, options) {
|
|
|
334
520
|
throw new Error('API key is required.')
|
|
335
521
|
}
|
|
336
522
|
|
|
523
|
+
const reportStatus = (info) => {
|
|
524
|
+
options.onStatus?.(info)
|
|
525
|
+
options.onProgress?.(info)
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
const uploadOptions = {
|
|
529
|
+
...options,
|
|
530
|
+
onStatus: reportStatus,
|
|
531
|
+
}
|
|
532
|
+
|
|
337
533
|
const { absolutePath, context } = await resolveUploadFile(filePath, {
|
|
338
534
|
mimeType: options.mimeType,
|
|
339
535
|
})
|
|
340
536
|
|
|
341
537
|
if (context.originalSize <= MULTIPART_THRESHOLD_BYTES) {
|
|
342
538
|
const encryptedBuffer = await collectEncryptedBuffer(absolutePath, context, {
|
|
343
|
-
onProgress:
|
|
539
|
+
onProgress: reportStatus,
|
|
344
540
|
signal: options.signal,
|
|
345
541
|
})
|
|
346
542
|
|
|
347
|
-
return uploadDirect(context, encryptedBuffer,
|
|
543
|
+
return uploadDirect(context, encryptedBuffer, uploadOptions)
|
|
348
544
|
}
|
|
349
545
|
|
|
350
|
-
return uploadMultipartStreaming(absolutePath, context,
|
|
546
|
+
return uploadMultipartStreaming(absolutePath, context, uploadOptions)
|
|
351
547
|
}
|