bytifi 0.2.6 → 0.2.8

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/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 || '').replace(/-/g, '+').replace(/_/g, '/')
12
- const padded = normalized.padEnd(normalized.length + ((4 - (normalized.length % 4)) % 4), '=')
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) {