codebuff 1.0.684 → 1.0.685

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/http.js CHANGED
@@ -1,5 +1,7 @@
1
1
  const http = require('http')
2
2
  const https = require('https')
3
+ const fs = require('fs')
4
+ const { pipeline } = require('stream/promises')
3
5
  const tls = require('tls')
4
6
 
5
7
  function createReleaseHttpClient({
@@ -8,9 +10,20 @@ function createReleaseHttpClient({
8
10
  requestTimeout,
9
11
  httpModule = http,
10
12
  httpsModule = https,
13
+ fsModule = fs,
14
+ pipelineFn = pipeline,
11
15
  tlsModule = tls,
12
16
  }) {
13
- function getProxyUrl() {
17
+ function getProxyUrl(protocol = 'https:') {
18
+ if (protocol === 'http:') {
19
+ return (
20
+ env.HTTP_PROXY ||
21
+ env.http_proxy ||
22
+ env.HTTPS_PROXY ||
23
+ env.https_proxy ||
24
+ null
25
+ )
26
+ }
14
27
  return (
15
28
  env.HTTPS_PROXY ||
16
29
  env.https_proxy ||
@@ -87,11 +100,15 @@ function createReleaseHttpClient({
87
100
  })
88
101
  }
89
102
 
90
- async function buildRequestOptions(url, options = {}) {
103
+ async function buildRequest(url, options = {}) {
91
104
  const parsedUrl = new URL(url)
105
+ if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
106
+ throw new Error(`Unsupported URL protocol: ${parsedUrl.protocol}`)
107
+ }
108
+ const isHttps = parsedUrl.protocol === 'https:'
92
109
  const reqOptions = {
93
110
  hostname: parsedUrl.hostname,
94
- port: parsedUrl.port || 443,
111
+ port: parsedUrl.port || (isHttps ? 443 : 80),
95
112
  path: parsedUrl.pathname + parsedUrl.search,
96
113
  headers: {
97
114
  'User-Agent': userAgent,
@@ -99,9 +116,31 @@ function createReleaseHttpClient({
99
116
  },
100
117
  }
101
118
 
102
- const proxyUrl = getProxyUrl()
119
+ const proxyUrl = getProxyUrl(parsedUrl.protocol)
103
120
  if (!proxyUrl || shouldBypassProxy(parsedUrl.hostname)) {
104
- return reqOptions
121
+ return { transport: isHttps ? httpsModule : httpModule, reqOptions }
122
+ }
123
+
124
+ const proxy = new URL(proxyUrl)
125
+ if (!['http:', 'https:'].includes(proxy.protocol)) {
126
+ throw new Error(`Unsupported proxy protocol: ${proxy.protocol}`)
127
+ }
128
+
129
+ if (!isHttps) {
130
+ reqOptions.hostname = proxy.hostname
131
+ reqOptions.port = proxy.port || (proxy.protocol === 'https:' ? 443 : 80)
132
+ reqOptions.path = parsedUrl.href
133
+ reqOptions.headers.Host = parsedUrl.host
134
+ if (proxy.username || proxy.password) {
135
+ const auth = Buffer.from(
136
+ `${decodeURIComponent(proxy.username || '')}:${decodeURIComponent(proxy.password || '')}`,
137
+ ).toString('base64')
138
+ reqOptions.headers['Proxy-Authorization'] = `Basic ${auth}`
139
+ }
140
+ return {
141
+ transport: proxy.protocol === 'https:' ? httpsModule : httpModule,
142
+ reqOptions,
143
+ }
105
144
  }
106
145
 
107
146
  const tunnelSocket = await connectThroughProxy(
@@ -138,36 +177,260 @@ function createReleaseHttpClient({
138
177
  }
139
178
 
140
179
  reqOptions.agent = new TunnelAgent({ keepAlive: false })
141
- return reqOptions
180
+ return { transport: httpsModule, reqOptions }
142
181
  }
143
182
 
144
183
  async function httpGet(url, options = {}) {
145
- const reqOptions = await buildRequestOptions(url, options)
184
+ const redirectCount = options.redirectCount || 0
185
+ const { transport, reqOptions } = await buildRequest(url, options)
146
186
 
147
187
  return new Promise((resolve, reject) => {
148
- const req = httpsModule.get(reqOptions, (res) => {
149
- if (res.statusCode === 301 || res.statusCode === 302) {
188
+ const req = transport.get(reqOptions, (res) => {
189
+ if ([301, 302, 303, 307, 308].includes(res.statusCode)) {
150
190
  res.resume()
151
- httpGet(new URL(res.headers.location, url).href, options)
191
+
192
+ if (!res.headers.location) {
193
+ reject(
194
+ new Error(`Redirect ${res.statusCode} missing Location header.`),
195
+ )
196
+ return
197
+ }
198
+ if (redirectCount >= (options.maxRedirects ?? 10)) {
199
+ reject(new Error('Too many redirects.'))
200
+ return
201
+ }
202
+
203
+ httpGet(new URL(res.headers.location, url).href, {
204
+ ...options,
205
+ redirectCount: redirectCount + 1,
206
+ })
152
207
  .then(resolve)
153
208
  .catch(reject)
154
209
  return
155
210
  }
156
211
 
212
+ res.requestUrl = url
157
213
  resolve(res)
158
214
  })
159
215
 
160
- req.on('error', reject)
216
+ req.on('error', (error) => {
217
+ error.requestUrl = url
218
+ reject(error)
219
+ })
161
220
  req.setTimeout(options.timeout || requestTimeout, () => {
162
221
  req.destroy()
163
- reject(new Error('Request timeout.'))
222
+ const error = new Error('Request timeout.')
223
+ error.code = 'ETIMEDOUT'
224
+ error.requestUrl = url
225
+ reject(error)
164
226
  })
165
227
  })
166
228
  }
167
229
 
230
+ function getFileSize(filePath) {
231
+ try {
232
+ return fsModule.statSync(filePath).size
233
+ } catch (error) {
234
+ if (error.code === 'ENOENT') return 0
235
+ throw error
236
+ }
237
+ }
238
+
239
+ function removeFileIfPresent(filePath) {
240
+ try {
241
+ fsModule.unlinkSync(filePath)
242
+ } catch (error) {
243
+ if (error.code !== 'ENOENT') throw error
244
+ }
245
+ }
246
+
247
+ function throwResponseError(res, message, requestUrl, retryable) {
248
+ res.resume()
249
+ const error = new Error(message)
250
+ error.statusCode = res.statusCode
251
+ error.retryable = retryable
252
+ error.requestUrl = requestUrl
253
+ throw error
254
+ }
255
+
256
+ function parseContentRange(value) {
257
+ if (!value) return null
258
+
259
+ const completeMatch = value.match(/^bytes (\d+)-(\d+)\/(\d+)$/i)
260
+ if (completeMatch) {
261
+ const start = Number(completeMatch[1])
262
+ const end = Number(completeMatch[2])
263
+ const total = Number(completeMatch[3])
264
+ if (start > end || end >= total) return null
265
+ return {
266
+ start,
267
+ end,
268
+ total,
269
+ }
270
+ }
271
+
272
+ const unsatisfiedMatch = value.match(/^bytes \*\/(\d+)$/i)
273
+ if (unsatisfiedMatch) {
274
+ return { start: null, end: null, total: Number(unsatisfiedMatch[1]) }
275
+ }
276
+
277
+ return null
278
+ }
279
+
280
+ function isRetryableStatus(statusCode) {
281
+ return (
282
+ statusCode === 408 ||
283
+ statusCode === 425 ||
284
+ statusCode === 429 ||
285
+ statusCode >= 500
286
+ )
287
+ }
288
+
289
+ async function downloadFile(url, destinationPath, options = {}) {
290
+ let resumedFrom = getFileSize(destinationPath)
291
+ let totalBytes = null
292
+
293
+ const headers = { ...options.headers }
294
+ if (resumedFrom > 0) {
295
+ headers.Range = `bytes=${resumedFrom}-`
296
+ }
297
+
298
+ const res = await httpGet(url, { ...options, headers })
299
+ const responseUrl = res.requestUrl || url
300
+ const contentRange = parseContentRange(res.headers['content-range'])
301
+
302
+ if (res.statusCode === 416 && contentRange?.total === resumedFrom) {
303
+ res.resume()
304
+ return {
305
+ downloadedBytes: resumedFrom,
306
+ totalBytes: resumedFrom,
307
+ resumedFrom,
308
+ responseUrl,
309
+ }
310
+ }
311
+
312
+ if (res.statusCode === 416) {
313
+ removeFileIfPresent(destinationPath)
314
+ throwResponseError(
315
+ res,
316
+ 'Saved partial download is no longer valid',
317
+ responseUrl,
318
+ true,
319
+ )
320
+ }
321
+
322
+ let writeFlags
323
+ let expectedResponseBytes = null
324
+ if (res.statusCode === 206) {
325
+ if (!contentRange || contentRange.start !== resumedFrom) {
326
+ removeFileIfPresent(destinationPath)
327
+ throwResponseError(
328
+ res,
329
+ `Download resume mismatch: expected byte ${resumedFrom}`,
330
+ responseUrl,
331
+ true,
332
+ )
333
+ }
334
+ totalBytes = contentRange.total
335
+ expectedResponseBytes = contentRange.end - contentRange.start + 1
336
+ writeFlags = 'a'
337
+ } else if (res.statusCode === 200) {
338
+ // The server may ignore Range. Restart safely instead of appending a
339
+ // complete response to the existing partial archive.
340
+ resumedFrom = 0
341
+ totalBytes = Number(res.headers['content-length']) || null
342
+ writeFlags = 'w'
343
+ } else {
344
+ throwResponseError(
345
+ res,
346
+ `Download failed: HTTP ${res.statusCode}`,
347
+ responseUrl,
348
+ isRetryableStatus(res.statusCode),
349
+ )
350
+ }
351
+
352
+ let downloadedBytes = resumedFrom
353
+ res.on('data', (chunk) => {
354
+ downloadedBytes += chunk.length
355
+ options.onProgress?.({ downloadedBytes, totalBytes, resumedFrom })
356
+ })
357
+
358
+ try {
359
+ await pipelineFn(
360
+ res,
361
+ fsModule.createWriteStream(destinationPath, { flags: writeFlags }),
362
+ )
363
+ } catch (error) {
364
+ error.requestUrl ||= responseUrl
365
+ error.downloadedBytes = getFileSize(destinationPath)
366
+ error.totalBytes = totalBytes
367
+ throw error
368
+ }
369
+
370
+ downloadedBytes = getFileSize(destinationPath)
371
+ const responseBytes = downloadedBytes - resumedFrom
372
+ if (
373
+ expectedResponseBytes !== null &&
374
+ responseBytes !== expectedResponseBytes
375
+ ) {
376
+ if (responseBytes > expectedResponseBytes) {
377
+ removeFileIfPresent(destinationPath)
378
+ }
379
+ const error = new Error(
380
+ `Download incomplete: response contained ${responseBytes} of ${expectedResponseBytes} bytes`,
381
+ )
382
+ error.code = 'EINCOMPLETE'
383
+ error.retryable = true
384
+ error.requestUrl = responseUrl
385
+ error.downloadedBytes = getFileSize(destinationPath)
386
+ error.totalBytes = totalBytes
387
+ throw error
388
+ }
389
+ if (totalBytes !== null && downloadedBytes !== totalBytes) {
390
+ const error = new Error(
391
+ `Download incomplete: received ${downloadedBytes} of ${totalBytes} bytes`,
392
+ )
393
+ error.code = 'EINCOMPLETE'
394
+ error.retryable = true
395
+ error.requestUrl = responseUrl
396
+ error.downloadedBytes = downloadedBytes
397
+ error.totalBytes = totalBytes
398
+ throw error
399
+ }
400
+
401
+ return { downloadedBytes, totalBytes, resumedFrom, responseUrl }
402
+ }
403
+
404
+ async function withRetries(
405
+ operation,
406
+ {
407
+ maxAttempts = 1,
408
+ baseDelayMs = 1000,
409
+ shouldRetry = () => true,
410
+ onRetry = () => {},
411
+ sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
412
+ },
413
+ ) {
414
+ for (let attempt = 1; ; attempt++) {
415
+ try {
416
+ return await operation(attempt)
417
+ } catch (error) {
418
+ if (attempt >= maxAttempts || !shouldRetry(error)) {
419
+ throw error
420
+ }
421
+
422
+ const delayMs = baseDelayMs * 2 ** (attempt - 1)
423
+ await onRetry({ error, attempt, nextAttempt: attempt + 1, delayMs })
424
+ await sleep(delayMs)
425
+ }
426
+ }
427
+ }
428
+
168
429
  return {
169
430
  getProxyUrl,
431
+ downloadFile,
170
432
  httpGet,
433
+ withRetries,
171
434
  }
172
435
  }
173
436