bytifi 0.2.6 → 0.2.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 -261
- package/lib/url.js +26 -0
- package/package.json +5 -2
package/lib/api.js
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { formatDuration } from './progress.js'
|
|
2
|
+
import { normalizeBaseUrl } from './url.js'
|
|
3
|
+
|
|
4
|
+
const MAX_RETRIES = 3
|
|
5
|
+
const UNLIMITED_RATE_LIMIT_RETRIES = Number.POSITIVE_INFINITY
|
|
6
|
+
|
|
7
|
+
export class BytifiApiError extends Error {
|
|
8
|
+
constructor(message, { status = 0, body = null, retryAfterMs = null } = {}) {
|
|
9
|
+
super(message)
|
|
10
|
+
this.name = 'BytifiApiError'
|
|
11
|
+
this.status = status
|
|
12
|
+
this.body = body
|
|
13
|
+
this.retryAfterMs = retryAfterMs
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class BytifiNetworkError extends Error {
|
|
18
|
+
constructor(message, { cause } = {}) {
|
|
19
|
+
super(message)
|
|
20
|
+
this.name = 'BytifiNetworkError'
|
|
21
|
+
this.cause = cause
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export { normalizeBaseUrl } from './url.js'
|
|
26
|
+
|
|
27
|
+
export function sleep(ms) {
|
|
28
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function parseRetryAfterMs(response) {
|
|
32
|
+
const retryAfter = response.headers.get('Retry-After')
|
|
33
|
+
if (retryAfter) {
|
|
34
|
+
const seconds = Number(retryAfter)
|
|
35
|
+
if (Number.isFinite(seconds) && seconds >= 0) {
|
|
36
|
+
return seconds * 1000
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const dateMs = Date.parse(retryAfter)
|
|
40
|
+
if (Number.isFinite(dateMs)) {
|
|
41
|
+
return Math.max(0, dateMs - Date.now())
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const resetHeader = response.headers.get('RateLimit-Reset')
|
|
46
|
+
if (resetHeader) {
|
|
47
|
+
const resetValue = Number(resetHeader)
|
|
48
|
+
if (Number.isFinite(resetValue)) {
|
|
49
|
+
if (resetValue > 1_000_000_000) {
|
|
50
|
+
return Math.max(0, resetValue * 1000 - Date.now())
|
|
51
|
+
}
|
|
52
|
+
return Math.max(0, resetValue * 1000)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function isRetryableError(error) {
|
|
60
|
+
if (error instanceof BytifiNetworkError) return true
|
|
61
|
+
if (error instanceof BytifiApiError) {
|
|
62
|
+
return error.status === 429 || error.status >= 500
|
|
63
|
+
}
|
|
64
|
+
return false
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function apiFetch(baseUrl, path, { apiKey, method = 'GET', headers = {}, body = null, signal, binary = false } = {}) {
|
|
68
|
+
const url = `${normalizeBaseUrl(baseUrl)}${path}`
|
|
69
|
+
const requestHeaders = {
|
|
70
|
+
...headers,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
if (apiKey) {
|
|
74
|
+
requestHeaders.Authorization = `Bearer ${apiKey}`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let response
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
response = await fetch(url, {
|
|
81
|
+
method,
|
|
82
|
+
headers: requestHeaders,
|
|
83
|
+
body,
|
|
84
|
+
signal,
|
|
85
|
+
})
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (signal?.aborted) {
|
|
88
|
+
throw new Error('Upload aborted.')
|
|
89
|
+
}
|
|
90
|
+
throw new BytifiNetworkError(error.message || 'Network request failed.', { cause: error })
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const responseBuffer = Buffer.from(await response.arrayBuffer())
|
|
94
|
+
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
const text = responseBuffer.toString('utf8')
|
|
97
|
+
let payload = text
|
|
98
|
+
try {
|
|
99
|
+
payload = JSON.parse(text)
|
|
100
|
+
} catch {
|
|
101
|
+
// keep text
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const message = typeof payload === 'object' && payload?.error
|
|
105
|
+
? payload.error
|
|
106
|
+
: typeof payload === 'string' && payload
|
|
107
|
+
? payload
|
|
108
|
+
: `Request failed with status ${response.status}.`
|
|
109
|
+
throw new BytifiApiError(message, {
|
|
110
|
+
status: response.status,
|
|
111
|
+
body: payload,
|
|
112
|
+
retryAfterMs: response.status === 429 ? parseRetryAfterMs(response) : null,
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (binary) {
|
|
117
|
+
return responseBuffer
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const text = responseBuffer.toString('utf8')
|
|
121
|
+
if (!text) return null
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
return JSON.parse(text)
|
|
125
|
+
} catch {
|
|
126
|
+
return text
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function apiFetchWithRetry(baseUrl, path, options = {}) {
|
|
131
|
+
const {
|
|
132
|
+
retries = MAX_RETRIES,
|
|
133
|
+
rateLimitRetries = 0,
|
|
134
|
+
maxRateLimitWaitMs = null,
|
|
135
|
+
onStatus,
|
|
136
|
+
signal,
|
|
137
|
+
...fetchOptions
|
|
138
|
+
} = options
|
|
139
|
+
|
|
140
|
+
let attempt = 0
|
|
141
|
+
let rateLimitAttempt = 0
|
|
142
|
+
const maxRateLimitRetries = rateLimitRetries === UNLIMITED_RATE_LIMIT_RETRIES
|
|
143
|
+
? UNLIMITED_RATE_LIMIT_RETRIES
|
|
144
|
+
: (rateLimitRetries || retries)
|
|
145
|
+
|
|
146
|
+
while (true) {
|
|
147
|
+
try {
|
|
148
|
+
return await apiFetch(baseUrl, path, { ...fetchOptions, signal })
|
|
149
|
+
} catch (error) {
|
|
150
|
+
if (signal?.aborted || error.message === 'Upload aborted.' || error.message === 'Decrypt aborted.') {
|
|
151
|
+
throw error
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (error instanceof BytifiApiError && error.status === 429) {
|
|
155
|
+
if (rateLimitAttempt >= maxRateLimitRetries) {
|
|
156
|
+
throw error
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
rateLimitAttempt += 1
|
|
160
|
+
const serverWaitMs = error.retryAfterMs ?? Math.min(600_000, 15_000 * rateLimitAttempt)
|
|
161
|
+
const waitMs = maxRateLimitWaitMs == null
|
|
162
|
+
? serverWaitMs
|
|
163
|
+
: Math.min(serverWaitMs, maxRateLimitWaitMs)
|
|
164
|
+
|
|
165
|
+
onStatus?.({
|
|
166
|
+
stage: 'waiting',
|
|
167
|
+
message: maxRateLimitWaitMs != null && serverWaitMs > waitMs
|
|
168
|
+
? `Rate limited — retrying in ${formatDuration(waitMs)} (part upload continues when server allows)…`
|
|
169
|
+
: `Rate limited — waiting ${formatDuration(waitMs)} before retry (${rateLimitAttempt})…`,
|
|
170
|
+
waitMs,
|
|
171
|
+
retryAttempt: rateLimitAttempt,
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
await sleep(waitMs)
|
|
175
|
+
continue
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (!isRetryableError(error) || attempt >= retries) {
|
|
179
|
+
throw error
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
attempt += 1
|
|
183
|
+
await sleep(Math.min(1000 * (2 ** attempt), 8000))
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export async function publicFetchWithRetry(baseUrl, path, options = {}) {
|
|
189
|
+
return apiFetchWithRetry(baseUrl, path, {
|
|
190
|
+
...options,
|
|
191
|
+
apiKey: null,
|
|
192
|
+
rateLimitRetries: options.rateLimitRetries ?? UNLIMITED_RATE_LIMIT_RETRIES,
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function fetchPublicBinaryWithRetry(baseUrl, path, options = {}) {
|
|
197
|
+
return apiFetchWithRetry(baseUrl, path, {
|
|
198
|
+
...options,
|
|
199
|
+
apiKey: null,
|
|
200
|
+
binary: true,
|
|
201
|
+
rateLimitRetries: options.rateLimitRetries ?? UNLIMITED_RATE_LIMIT_RETRIES,
|
|
202
|
+
})
|
|
203
|
+
}
|
package/lib/base64url.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
const BASE64URL_CHARSET = /^[A-Za-z0-9_-]*$/
|
|
2
|
+
|
|
1
3
|
export function toBase64Url(bytes) {
|
|
2
4
|
const buffer = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes)
|
|
3
5
|
return buffer
|
|
@@ -8,7 +10,13 @@ export function toBase64Url(bytes) {
|
|
|
8
10
|
}
|
|
9
11
|
|
|
10
12
|
export function fromBase64Url(value) {
|
|
11
|
-
const normalized = String(value || '')
|
|
12
|
-
|
|
13
|
+
const normalized = String(value || '')
|
|
14
|
+
|
|
15
|
+
if (!BASE64URL_CHARSET.test(normalized)) {
|
|
16
|
+
throw new Error('Invalid base64url string: contains disallowed characters.')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const padded = normalized.replace(/-/g, '+').replace(/_/g, '/')
|
|
20
|
+
.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '=')
|
|
13
21
|
return Buffer.from(padded, 'base64')
|
|
14
22
|
}
|
package/lib/crypto.js
CHANGED
|
@@ -10,7 +10,6 @@ const gunzipAsync = promisify(zlib.gunzip)
|
|
|
10
10
|
|
|
11
11
|
export const ENCRYPTED_PART_SIZE = 32 * 1024 * 1024
|
|
12
12
|
export const PLAIN_CHUNK_SIZE = ENCRYPTED_PART_SIZE - 16
|
|
13
|
-
export const DIRECT_UPLOAD_LIMIT_BYTES = 100 * 1024 * 1024
|
|
14
13
|
export const MULTIPART_THRESHOLD_BYTES = 10 * 1024 * 1024
|
|
15
14
|
|
|
16
15
|
export function buildChunkIv(noncePrefix, chunkIndex) {
|