@juzi/file-box 1.7.18 → 1.7.20

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.
Files changed (46) hide show
  1. package/dist/cjs/src/file-box.d.ts.map +1 -1
  2. package/dist/cjs/src/file-box.js +2 -2
  3. package/dist/cjs/src/file-box.js.map +1 -1
  4. package/dist/cjs/src/file-box.spec.d.ts.map +1 -1
  5. package/dist/cjs/src/file-box.spec.js +26 -2
  6. package/dist/cjs/src/file-box.spec.js.map +1 -1
  7. package/dist/cjs/src/misc.d.ts.map +1 -1
  8. package/dist/cjs/src/misc.js +154 -27
  9. package/dist/cjs/src/misc.js.map +1 -1
  10. package/dist/cjs/src/misc.spec.js +101 -14
  11. package/dist/cjs/src/misc.spec.js.map +1 -1
  12. package/dist/cjs/src/version.js +1 -1
  13. package/dist/cjs/tests/chunk-download.spec.d.ts +3 -0
  14. package/dist/cjs/tests/chunk-download.spec.d.ts.map +1 -0
  15. package/dist/cjs/tests/chunk-download.spec.js +302 -0
  16. package/dist/cjs/tests/chunk-download.spec.js.map +1 -0
  17. package/dist/cjs/tests/misc-error-handling.spec.d.ts +3 -0
  18. package/dist/cjs/tests/misc-error-handling.spec.d.ts.map +1 -0
  19. package/dist/cjs/tests/misc-error-handling.spec.js +258 -0
  20. package/dist/cjs/tests/misc-error-handling.spec.js.map +1 -0
  21. package/dist/esm/src/file-box.d.ts.map +1 -1
  22. package/dist/esm/src/file-box.js +2 -2
  23. package/dist/esm/src/file-box.js.map +1 -1
  24. package/dist/esm/src/file-box.spec.d.ts.map +1 -1
  25. package/dist/esm/src/file-box.spec.js +27 -3
  26. package/dist/esm/src/file-box.spec.js.map +1 -1
  27. package/dist/esm/src/misc.d.ts.map +1 -1
  28. package/dist/esm/src/misc.js +155 -28
  29. package/dist/esm/src/misc.js.map +1 -1
  30. package/dist/esm/src/misc.spec.js +101 -14
  31. package/dist/esm/src/misc.spec.js.map +1 -1
  32. package/dist/esm/src/version.js +1 -1
  33. package/dist/esm/tests/chunk-download.spec.d.ts +3 -0
  34. package/dist/esm/tests/chunk-download.spec.d.ts.map +1 -0
  35. package/dist/esm/tests/chunk-download.spec.js +300 -0
  36. package/dist/esm/tests/chunk-download.spec.js.map +1 -0
  37. package/dist/esm/tests/misc-error-handling.spec.d.ts +3 -0
  38. package/dist/esm/tests/misc-error-handling.spec.d.ts.map +1 -0
  39. package/dist/esm/tests/misc-error-handling.spec.js +256 -0
  40. package/dist/esm/tests/misc-error-handling.spec.js.map +1 -0
  41. package/package.json +10 -3
  42. package/src/file-box.spec.ts +34 -7
  43. package/src/file-box.ts +5 -5
  44. package/src/misc.spec.ts +111 -17
  45. package/src/misc.ts +140 -31
  46. package/src/version.ts +1 -1
@@ -2,13 +2,15 @@
2
2
 
3
3
  import 'reflect-metadata'
4
4
 
5
- import assert from 'assert'
6
- import { PassThrough, Readable } from 'stream'
7
- import { test, sinon } from 'tstest'
8
- import { instanceToClass } from 'clone-class'
5
+ import assert from 'assert'
6
+ import { instanceToClass } from 'clone-class'
7
+ import { createServer } from 'http'
8
+ import type { AddressInfo } from 'net'
9
+ import { PassThrough, Readable } from 'stream'
10
+ import { sinon, test } from 'tstest'
9
11
 
10
- import { FileBox } from './file-box.js'
11
- import { FileBoxType } from './file-box.type.js'
12
+ import { FileBox } from './file-box.js'
13
+ import { FileBoxType } from './file-box.type.js'
12
14
 
13
15
  const requiredMetadataKey = Symbol('required')
14
16
 
@@ -113,7 +115,32 @@ test('syncRemote()', async t => {
113
115
 
114
116
  }
115
117
 
116
- const URL = 'http://httpbin.org/response-headers?Content-Disposition=attachment;%20filename%3d%22test.txt%22&filename=test.txt'
118
+ /**
119
+ * 使用本地 server,避免依赖外网(httpbin 不稳定/可能被墙/会返回 html)
120
+ */
121
+ const server = createServer((req, res) => {
122
+ if (req.method === 'HEAD' && req.url?.startsWith('/response-headers')) {
123
+ res.writeHead(200, {
124
+ 'Content-Disposition': 'attachment; filename="test.txt"',
125
+ 'Content-Length': '159',
126
+ 'Content-Type': 'application/json',
127
+ })
128
+ res.end()
129
+ return
130
+ }
131
+ res.writeHead(404)
132
+ res.end()
133
+ })
134
+
135
+ const host = await new Promise<string>((resolve) => {
136
+ server.listen(0, '127.0.0.1', () => {
137
+ const addr = server.address() as AddressInfo
138
+ resolve(`http://127.0.0.1:${addr.port}`)
139
+ })
140
+ })
141
+ t.teardown(() => { server.close() })
142
+
143
+ const URL = `${host}/response-headers?Content-Disposition=attachment;%20filename%3d%22test.txt%22&filename=test.txt`
117
144
 
118
145
  const EXPECTED_NAME_FROM_URL = 'response-headers'
119
146
  const EXPECTED_TYPE_FROM_URL = 'application/unknown'
package/src/file-box.ts CHANGED
@@ -385,10 +385,10 @@ class FileBox implements Pipeable, FileBoxInterface {
385
385
 
386
386
  case FileBoxType.Url:
387
387
  fileBox = this.fromUrl(obj.url, {
388
+ headers: obj.headers,
388
389
  md5: obj.md5,
389
390
  name: obj.name,
390
391
  size: obj.size,
391
- headers: obj.headers,
392
392
  })
393
393
  break
394
394
 
@@ -510,12 +510,12 @@ class FileBox implements Pipeable, FileBoxInterface {
510
510
  private readonly qrCode? : string
511
511
  private readonly uuid? : string
512
512
 
513
- get url() {
513
+ get url () {
514
514
  return this.remoteUrl
515
515
  }
516
516
 
517
517
  private proxyUrl?: string
518
- useProxyUrl(url?: string) {
518
+ useProxyUrl (url?: string) {
519
519
  this.proxyUrl = url
520
520
  }
521
521
 
@@ -618,7 +618,7 @@ class FileBox implements Pipeable, FileBoxInterface {
618
618
 
619
619
  async ready (): Promise<void> {
620
620
  let tryCount = 0
621
- while (1) {
621
+ while (true) {
622
622
  try {
623
623
  switch (this.type) {
624
624
  case FileBoxType.Url:
@@ -636,7 +636,7 @@ class FileBox implements Pipeable, FileBoxInterface {
636
636
  }
637
637
  break
638
638
  } catch (e) {
639
- tryCount ++
639
+ tryCount++
640
640
  if (tryCount >= READY_RETRY) {
641
641
  throw e
642
642
  }
package/src/misc.spec.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env -S node --no-warnings --loader ts-node/esm
2
2
 
3
3
  // tslint:disable:no-shadowed-variable
4
+ import { createServer } from 'http'
5
+ import type { AddressInfo } from 'net'
4
6
  import { test } from 'tstest'
5
7
 
6
8
  import {
@@ -27,14 +29,42 @@ test('dataUrl to base64', async t => {
27
29
  })
28
30
 
29
31
  test('httpHeadHeader', async t => {
30
- const URL = 'https://github.com/huan/file-box/archive/v0.6.tar.gz'
31
-
32
- const EXPECTED_HEADERS_KEY = 'content-disposition'
33
- const EXPECTED_HEADERS_VALUE = 'attachment; filename=file-box-0.6.tar.gz'
34
-
35
- const headers = await httpHeadHeader(URL)
36
-
37
- t.equal(headers[EXPECTED_HEADERS_KEY], EXPECTED_HEADERS_VALUE, 'should get the headers right')
32
+ /**
33
+ * 使用本地 server,避免依赖外网(CI/本地网络不稳定会导致 flaky)
34
+ * 同时覆盖:302 Location 为相对路径的跳转逻辑
35
+ */
36
+ const server = createServer((req, res) => {
37
+ if (req.url === '/redirect') {
38
+ res.writeHead(302, { Location: '/final' })
39
+ res.end()
40
+ return
41
+ }
42
+ if (req.url === '/final') {
43
+ res.writeHead(200, {
44
+ 'Content-Disposition': 'attachment; filename=file-box-0.6.tar.gz',
45
+ 'Content-Length': '0',
46
+ })
47
+ res.end()
48
+ return
49
+ }
50
+ res.writeHead(404)
51
+ res.end()
52
+ })
53
+
54
+ const host = await new Promise<string>((resolve) => {
55
+ server.listen(0, '127.0.0.1', () => {
56
+ const addr = server.address() as AddressInfo
57
+ resolve(`http://127.0.0.1:${addr.port}`)
58
+ })
59
+ })
60
+ t.teardown(() => { server.close() })
61
+
62
+ const headers = await httpHeadHeader(`${host}/redirect`)
63
+ t.equal(
64
+ headers['content-disposition'],
65
+ 'attachment; filename=file-box-0.6.tar.gz',
66
+ 'should get the headers right',
67
+ )
38
68
  })
39
69
 
40
70
  test('httpHeaderToFileName', async t => {
@@ -54,7 +84,18 @@ test('httpHeaderToFileName', async t => {
54
84
  })
55
85
 
56
86
  test('httpStream', async t => {
57
- const URL = 'https://httpbin.org/headers'
87
+ const server = createServer((req, res) => {
88
+ res.writeHead(200, { 'Content-Type': 'application/json' })
89
+ res.end(JSON.stringify({ headers: req.headers }))
90
+ })
91
+
92
+ const host = await new Promise<string>((resolve) => {
93
+ server.listen(0, '127.0.0.1', () => {
94
+ const addr = server.address() as AddressInfo
95
+ resolve(`http://127.0.0.1:${addr.port}`)
96
+ })
97
+ })
98
+ t.teardown(() => { server.close() })
58
99
 
59
100
  const MOL_KEY = 'Mol'
60
101
  const MOL_VAL = '42'
@@ -62,19 +103,72 @@ test('httpStream', async t => {
62
103
  const headers = {} as { [idx: string]: string }
63
104
  headers[MOL_KEY] = MOL_VAL
64
105
 
65
- const res = await httpStream(URL, headers)
106
+ const res = await httpStream(`${host}/headers`, headers)
66
107
 
67
108
  const buffer = await streamToBuffer(res)
68
109
  const obj = JSON.parse(buffer.toString())
69
- t.equal(obj.headers[MOL_KEY], MOL_VAL, 'should send the header right')
110
+ // Node 会把 header name 规范成小写
111
+ t.equal(obj.headers[MOL_KEY.toLowerCase()], MOL_VAL, 'should send the header right')
70
112
  })
71
113
 
72
114
  test('httpStream in chunks', async (t) => {
73
- const URL = 'https://media.w3.org/2010/05/sintel/trailer.mp4'
74
- const res = await httpStream(URL)
75
- let length = 0
76
- for await (const chunk of res) {
77
- length += chunk.length
115
+ const FILE_SIZE = 1024 * 1024 + 123 // > 默认 chunk size 触发分片逻辑
116
+ const content = Buffer.alloc(FILE_SIZE, 'A')
117
+
118
+ const server = createServer((req, res) => {
119
+ // HEAD:让 httpStream 判断是否支持 range + content-length
120
+ if (req.method === 'HEAD') {
121
+ res.writeHead(200, {
122
+ 'Accept-Ranges': 'bytes',
123
+ 'Content-Length': String(FILE_SIZE),
124
+ })
125
+ res.end()
126
+ return
127
+ }
128
+
129
+ const range = req.headers.range
130
+ if (range) {
131
+ const m = String(range).match(/bytes=(\d+)-(\d+)/)
132
+ if (!m) {
133
+ res.writeHead(416)
134
+ res.end()
135
+ return
136
+ }
137
+ const start = Number(m[1])
138
+ const end = Number(m[2])
139
+ const chunk = content.subarray(start, end + 1)
140
+ res.writeHead(206, {
141
+ 'Accept-Ranges': 'bytes',
142
+ 'Content-Length': String(chunk.length),
143
+ 'Content-Range': `bytes ${start}-${end}/${FILE_SIZE}`,
144
+ })
145
+ res.end(chunk)
146
+ return
147
+ }
148
+
149
+ res.writeHead(200, {
150
+ 'Accept-Ranges': 'bytes',
151
+ 'Content-Length': String(FILE_SIZE),
152
+ })
153
+ res.end(content)
154
+ })
155
+
156
+ const host = await new Promise<string>((resolve) => {
157
+ server.listen(0, '127.0.0.1', () => {
158
+ const addr = server.address() as AddressInfo
159
+ resolve(`http://127.0.0.1:${addr.port}`)
160
+ })
161
+ })
162
+ t.teardown(() => { server.close() })
163
+
164
+ const originalChunkSize = process.env['FILEBOX_HTTP_CHUNK_SIZE']
165
+ process.env['FILEBOX_HTTP_CHUNK_SIZE'] = String(256 * 1024)
166
+ try {
167
+ const res = await httpStream(`${host}/file`)
168
+ const buffer = await streamToBuffer(res)
169
+ t.equal(buffer.length, FILE_SIZE, 'should get data in chunks right')
170
+ } finally {
171
+ if (originalChunkSize) process.env['FILEBOX_HTTP_CHUNK_SIZE'] = originalChunkSize
172
+ else delete process.env['FILEBOX_HTTP_CHUNK_SIZE']
78
173
  }
79
- t.equal(length, 4372373, 'should get data in chunks right')
80
174
  })
package/src/misc.ts CHANGED
@@ -15,7 +15,6 @@ import {
15
15
  HTTP_CHUNK_SIZE,
16
16
  HTTP_REQUEST_TIMEOUT,
17
17
  HTTP_RESPONSE_TIMEOUT,
18
- NO_SLICE_DOWN,
19
18
  } from './config.js'
20
19
 
21
20
  const protocolMap: {
@@ -25,6 +24,9 @@ const protocolMap: {
25
24
  'https:': { agent: https.globalAgent, request: https.request },
26
25
  }
27
26
 
27
+ const noop = () => { }
28
+ const unsupportedRangeDomains = new Set<string>()
29
+
28
30
  function getProtocol (protocol: string) {
29
31
  assert(protocolMap[protocol], new Error('unknown protocol: ' + protocol))
30
32
  return protocolMap[protocol]!
@@ -51,8 +53,8 @@ export async function httpHeadHeader (url: string, headers: http.OutgoingHttpHea
51
53
  }
52
54
 
53
55
  const res = await fetch(url, {
54
- method: 'HEAD',
55
56
  headers,
57
+ method: 'HEAD',
56
58
  }, proxyUrl)
57
59
  res.destroy()
58
60
 
@@ -69,7 +71,8 @@ export async function httpHeadHeader (url: string, headers: http.OutgoingHttpHea
69
71
  throw new Error('302 found but no location!')
70
72
  }
71
73
 
72
- url = res.headers.location
74
+ // Location 可能是相对路径,需要以当前 url 作为 base 解析
75
+ url = new URL(res.headers.location, url).toString()
73
76
  }
74
77
  }
75
78
 
@@ -94,9 +97,9 @@ export async function httpStream (url: string, headers: http.OutgoingHttpHeaders
94
97
  const headHeaders = await httpHeadHeader(url, headers, proxyUrl)
95
98
  if (headHeaders.location) {
96
99
  url = headHeaders.location
97
- const { protocol } = new URL(url)
98
- getProtocol(protocol)
99
100
  }
101
+ const { protocol, hostname } = new URL(url)
102
+ getProtocol(protocol)
100
103
 
101
104
  const options: http.RequestOptions = {
102
105
  headers: { ...headers },
@@ -105,8 +108,12 @@ export async function httpStream (url: string, headers: http.OutgoingHttpHeaders
105
108
 
106
109
  const fileSize = Number(headHeaders['content-length'])
107
110
 
108
- if (!NO_SLICE_DOWN && headHeaders['accept-ranges'] === 'bytes' && fileSize > HTTP_CHUNK_SIZE) {
109
- return await downloadFileInChunks(url, options, fileSize, HTTP_CHUNK_SIZE, proxyUrl)
111
+ // 运行时读取 env:方便测试/调用方动态调整
112
+ const noSliceDown = process.env['FILEBOX_NO_SLICE_DOWN'] === 'true'
113
+ const chunkSize = Number(process.env['FILEBOX_HTTP_CHUNK_SIZE']) || HTTP_CHUNK_SIZE
114
+
115
+ if (!unsupportedRangeDomains.has(hostname) && !noSliceDown && headHeaders['accept-ranges'] === 'bytes' && fileSize > chunkSize) {
116
+ return await downloadFileInChunks(url, options, fileSize, chunkSize, proxyUrl)
110
117
  } else {
111
118
  return await fetch(url, options, proxyUrl)
112
119
  }
@@ -115,32 +122,81 @@ export async function httpStream (url: string, headers: http.OutgoingHttpHeaders
115
122
  async function fetch (url: string, options: http.RequestOptions, proxyUrl?: string): Promise<http.IncomingMessage> {
116
123
  const { protocol } = new URL(url)
117
124
  const { request, agent } = getProtocol(protocol)
125
+ const abortController = new AbortController()
126
+ const signal = abortController.signal
118
127
  const opts: http.RequestOptions = {
119
128
  agent,
120
129
  ...options,
130
+ signal,
121
131
  }
122
132
  setProxy(opts, proxyUrl)
133
+
123
134
  const req = request(url, opts)
124
- req
125
- .on('error', () => {
126
- req.destroy()
135
+ let res: http.IncomingMessage | undefined
136
+
137
+ // 兜底:任何时候 req.error 都不会变成 uncaughtException
138
+ const onReqError = (err: unknown) => {
139
+ const error = err instanceof Error ? err : new Error(String(err))
140
+ // 统一用 abort 中止请求:signal 已挂在 req 上,并且我们会把 abort(reason) 桥接到 res.destroy(...)
141
+ abortController.abort(error)
142
+ }
143
+ req.on('error', noop)
144
+ .on('error', onReqError)
145
+ .once('close', () => {
146
+ // close 后禁用 request timeout,避免定时器晚到触发 destroy -> error 无人监听
147
+ try { req.setTimeout(0) } catch { }
148
+ req.off('error', onReqError)
149
+ req.off('error', noop)
127
150
  })
151
+ // request timeout:只用于“拿到 response 之前”(连接/握手/首包)
128
152
  .setTimeout(HTTP_REQUEST_TIMEOUT, () => {
129
- req.emit('error', new Error(`FileBox: Http request timeout (${HTTP_REQUEST_TIMEOUT})!`))
153
+ // 已经拿到 response 时,不要再用 request timeout 误伤(会导致 aborted/ECONNRESET)
154
+ if (res) return
155
+ abortController.abort(new Error(`FileBox: Http request timeout (${HTTP_REQUEST_TIMEOUT})!`))
130
156
  })
131
157
  .end()
132
- const responseEvents = await once(req, 'response')
133
- const res = responseEvents[0] as http.IncomingMessage
134
- res
135
- .on('error', () => {
136
- res.destroy()
158
+
159
+ try {
160
+ const responseEvent = await once(req, 'response', { signal })
161
+ res = responseEvent[0] as http.IncomingMessage
162
+ // response 到来后清掉 request timeout,避免误伤长下载导致 aborted/ECONNRESET
163
+ try { req.setTimeout(0) } catch {}
164
+ // 必须尽早挂,避免 “response 刚到就 error/abort” 的竞态导致 uncaughtException
165
+ res.on('error', noop)
166
+ signal.throwIfAborted()
167
+ } catch (e) {
168
+ // once(...) 被 signal abort 时通常会抛 AbortError;优先抛出 abort(reason) 的真实原因
169
+ const reason = signal.reason as unknown
170
+ const err = reason instanceof Error
171
+ ? reason
172
+ : (e instanceof Error ? e : new Error(String(e)))
173
+ // 失败时尽量主动清理,避免 socket 悬挂(destroy 重复调用是安全的)
174
+ try { res?.destroy(err) } catch {}
175
+ try { req.destroy(err) } catch {}
176
+ throw err
177
+ }
178
+
179
+ const onAbort = () => {
180
+ const reason = signal.reason as unknown
181
+ res?.destroy(reason instanceof Error ? reason : new Error(String(reason)))
182
+ }
183
+ signal.addEventListener('abort', onAbort, { once: true })
184
+ res!
185
+ .once('end', () => { try { res!.setTimeout(0) } catch { } })
186
+ .once('close', () => {
187
+ // close 时做清理/兜底判断(尽力而为)
188
+ try { res!.setTimeout(0) } catch { }
189
+ if (!res!.complete && !res!.destroyed) {
190
+ // 有些场景不会 emit 'aborted',用 close + complete 兜底一次
191
+ res!.destroy(new Error('FileBox: Http response aborted!'))
192
+ }
193
+ signal.removeEventListener('abort', onAbort)
194
+ res!.off('error', noop)
137
195
  })
138
- if (res.socket) {
139
- res.setTimeout(HTTP_RESPONSE_TIMEOUT, () => {
140
- res.emit('error', new Error(`FileBox: Http response timeout (${HTTP_RESPONSE_TIMEOUT})!`))
196
+ .setTimeout(HTTP_RESPONSE_TIMEOUT, () => {
197
+ abortController.abort(new Error(`FileBox: Http response timeout (${HTTP_RESPONSE_TIMEOUT})!`))
141
198
  })
142
- }
143
- return res
199
+ return res!
144
200
  }
145
201
 
146
202
  async function downloadFileInChunks (
@@ -152,6 +208,12 @@ async function downloadFileInChunks (
152
208
  ): Promise<Readable> {
153
209
  const tmpFile = join(tmpdir(), `filebox-${randomUUID()}`)
154
210
  const writeStream = createWriteStream(tmpFile)
211
+ const writeAbortController = new AbortController()
212
+ const signal = writeAbortController.signal
213
+ const onWriteError = (err: unknown) => {
214
+ writeAbortController.abort(err instanceof Error ? err : new Error(String(err)))
215
+ }
216
+ writeStream.once('error', onWriteError)
155
217
  const allowStatusCode = [ 200, 206 ]
156
218
  const requestBaseOptions: http.RequestOptions = {
157
219
  headers: {},
@@ -168,37 +230,72 @@ async function downloadFileInChunks (
168
230
  const range = `bytes=${start}-${end}`
169
231
  const requestOptions = Object.assign({}, requestBaseOptions)
170
232
  assert(requestOptions.headers, 'Errors that should not happen: Invalid headers')
171
- requestOptions.headers['Range'] = range
233
+ ;(requestOptions.headers as http.OutgoingHttpHeaders)['Range'] = range
172
234
 
173
235
  try {
174
236
  const res = await fetch(url, requestOptions, proxyUrl)
237
+ if (res.statusCode === 416) {
238
+ unsupportedRangeDomains.add(new URL(url).hostname)
239
+ // 某些云服务商对分片下载的支持可能不规范,需要保留一个回退的方式
240
+ writeStream.destroy()
241
+ try {
242
+ await once(writeStream, 'close', { signal })
243
+ } catch {}
244
+ await rm(tmpFile, { force: true })
245
+ return await fetch(url, requestBaseOptions, proxyUrl)
246
+ }
175
247
  assert(allowStatusCode.includes(res.statusCode ?? 0), `Request failed with status code ${res.statusCode}`)
176
248
  assert(Number(res.headers['content-length']) > 0, 'Server returned 0 bytes of data')
249
+ try {
250
+ const { total } = parseContentRange(res.headers['content-range'] ?? '')
251
+ if (total > 0 && total < fileSize) {
252
+ // 某些云服务商(如腾讯云)在 head 方法中返回的 size 是原图大小,但下载时返回的是压缩后的图片,会比原图小。
253
+ // 这种在首次下载时虽然请求了原图大小的范围,可能比缩略图大,但会一次性返回完整的原图,而不是报错 416,通过修正 fileSize 跳出循环即可。
254
+ fileSize = total
255
+ }
256
+ } catch (error) {}
177
257
  for await (const chunk of res) {
178
258
  assert(Buffer.isBuffer(chunk))
179
259
  downSize += chunk.length
180
- writeStream.write(chunk)
260
+ if (!writeStream.write(chunk)) {
261
+ try {
262
+ await once(writeStream, 'drain', { signal })
263
+ } catch (e) {
264
+ const reason = signal.reason as unknown
265
+ throw reason instanceof Error ? reason : (e as Error)
266
+ }
267
+ }
181
268
  }
182
269
  res.destroy()
183
270
  } catch (error) {
184
- const err = error as Error
271
+ const err = error instanceof Error ? error : new Error(String(error))
185
272
  if (--retries <= 0) {
273
+ writeStream.destroy()
186
274
  void rm(tmpFile, { force: true })
187
- writeStream.close()
188
275
  throw new Error(`Download file with chunk failed! ${err.message}`, { cause: err })
189
276
  }
190
277
  }
191
278
  chunkSeq++
192
279
  start = downSize
193
280
  }
194
- writeStream.close()
281
+
282
+ writeStream.end()
283
+ try {
284
+ await once(writeStream, 'finish', { signal })
285
+ } catch (e) {
286
+ const reason = signal.reason as unknown
287
+ if (reason instanceof Error) {
288
+ throw reason
289
+ }
290
+ throw e
291
+ } finally {
292
+ writeStream.off('error', onWriteError)
293
+ }
195
294
 
196
295
  const readStream = createReadStream(tmpFile)
197
- readStream
198
- .once('end', () => readStream.close())
199
- .once('close', () => {
200
- void rm(tmpFile, { force: true })
201
- })
296
+ readStream.once('close', () => {
297
+ rm(tmpFile, { force: true }).catch(() => {})
298
+ })
202
299
  return readStream
203
300
  }
204
301
 
@@ -216,3 +313,15 @@ function setProxy (options: RequestOptions, proxyUrl?: string): void {
216
313
  options.agent = agent
217
314
  }
218
315
  }
316
+
317
+ function parseContentRange (contentRange: string): { start: number, end: number, total: number } {
318
+ const matches = contentRange.match(/bytes (\d+)-(\d+)\/(\d+)/)
319
+ if (!matches) {
320
+ throw new Error('Invalid content range')
321
+ }
322
+ return {
323
+ end: Number(matches[2]),
324
+ start: Number(matches[1]),
325
+ total: Number(matches[3]),
326
+ }
327
+ }
package/src/version.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  /**
2
2
  * This file was auto generated from scripts/generate-version.sh
3
3
  */
4
- export const VERSION: string = '1.7.18'
4
+ export const VERSION: string = '1.7.20'