@juzi/file-box 1.7.19 → 1.8.0

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 (64) hide show
  1. package/README.md +2 -2
  2. package/dist/cjs/src/config.d.ts +0 -1
  3. package/dist/cjs/src/config.d.ts.map +1 -1
  4. package/dist/cjs/src/config.js +1 -3
  5. package/dist/cjs/src/config.js.map +1 -1
  6. package/dist/cjs/src/file-box.d.ts.map +1 -1
  7. package/dist/cjs/src/file-box.js +2 -2
  8. package/dist/cjs/src/file-box.js.map +1 -1
  9. package/dist/cjs/src/file-box.spec.d.ts.map +1 -1
  10. package/dist/cjs/src/file-box.spec.js +26 -2
  11. package/dist/cjs/src/file-box.spec.js.map +1 -1
  12. package/dist/cjs/src/misc.d.ts.map +1 -1
  13. package/dist/cjs/src/misc.js +254 -50
  14. package/dist/cjs/src/misc.js.map +1 -1
  15. package/dist/cjs/src/misc.spec.js +105 -15
  16. package/dist/cjs/src/misc.spec.js.map +1 -1
  17. package/dist/cjs/src/version.d.ts.map +1 -1
  18. package/dist/cjs/src/version.js +1 -1
  19. package/dist/cjs/src/version.js.map +1 -1
  20. package/dist/cjs/tests/chunk-download.spec.d.ts +3 -0
  21. package/dist/cjs/tests/chunk-download.spec.d.ts.map +1 -0
  22. package/dist/cjs/tests/chunk-download.spec.js +318 -0
  23. package/dist/cjs/tests/chunk-download.spec.js.map +1 -0
  24. package/dist/cjs/tests/misc-error-handling.spec.d.ts +3 -0
  25. package/dist/cjs/tests/misc-error-handling.spec.d.ts.map +1 -0
  26. package/dist/cjs/tests/misc-error-handling.spec.js +352 -0
  27. package/dist/cjs/tests/misc-error-handling.spec.js.map +1 -0
  28. package/dist/cjs/tests/network-timeout.spec.js +15 -2
  29. package/dist/cjs/tests/network-timeout.spec.js.map +1 -1
  30. package/dist/esm/src/config.d.ts +0 -1
  31. package/dist/esm/src/config.d.ts.map +1 -1
  32. package/dist/esm/src/config.js +0 -2
  33. package/dist/esm/src/config.js.map +1 -1
  34. package/dist/esm/src/file-box.d.ts.map +1 -1
  35. package/dist/esm/src/file-box.js +2 -2
  36. package/dist/esm/src/file-box.js.map +1 -1
  37. package/dist/esm/src/file-box.spec.d.ts.map +1 -1
  38. package/dist/esm/src/file-box.spec.js +27 -3
  39. package/dist/esm/src/file-box.spec.js.map +1 -1
  40. package/dist/esm/src/misc.d.ts.map +1 -1
  41. package/dist/esm/src/misc.js +256 -52
  42. package/dist/esm/src/misc.js.map +1 -1
  43. package/dist/esm/src/misc.spec.js +105 -15
  44. package/dist/esm/src/misc.spec.js.map +1 -1
  45. package/dist/esm/src/version.d.ts.map +1 -1
  46. package/dist/esm/src/version.js +1 -1
  47. package/dist/esm/src/version.js.map +1 -1
  48. package/dist/esm/tests/chunk-download.spec.d.ts +3 -0
  49. package/dist/esm/tests/chunk-download.spec.d.ts.map +1 -0
  50. package/dist/esm/tests/chunk-download.spec.js +316 -0
  51. package/dist/esm/tests/chunk-download.spec.js.map +1 -0
  52. package/dist/esm/tests/misc-error-handling.spec.d.ts +3 -0
  53. package/dist/esm/tests/misc-error-handling.spec.d.ts.map +1 -0
  54. package/dist/esm/tests/misc-error-handling.spec.js +350 -0
  55. package/dist/esm/tests/misc-error-handling.spec.js.map +1 -0
  56. package/dist/esm/tests/network-timeout.spec.js +15 -2
  57. package/dist/esm/tests/network-timeout.spec.js.map +1 -1
  58. package/package.json +11 -4
  59. package/src/config.ts +0 -3
  60. package/src/file-box.spec.ts +34 -7
  61. package/src/file-box.ts +5 -5
  62. package/src/misc.spec.ts +120 -18
  63. package/src/misc.ts +242 -57
  64. package/src/version.ts +1 -1
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,33 @@ 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
+ const content = JSON.stringify({ headers: req.headers })
89
+
90
+ // Handle HEAD requests
91
+ if (req.method === 'HEAD') {
92
+ res.writeHead(200, {
93
+ 'Content-Length': String(content.length),
94
+ 'Content-Type': 'application/json',
95
+ })
96
+ res.end()
97
+ return
98
+ }
99
+
100
+ res.writeHead(200, {
101
+ 'Content-Length': String(content.length),
102
+ 'Content-Type': 'application/json',
103
+ })
104
+ res.end(content)
105
+ })
106
+
107
+ const host = await new Promise<string>((resolve) => {
108
+ server.listen(0, '127.0.0.1', () => {
109
+ const addr = server.address() as AddressInfo
110
+ resolve(`http://127.0.0.1:${addr.port}`)
111
+ })
112
+ })
113
+ t.teardown(() => { server.close() })
58
114
 
59
115
  const MOL_KEY = 'Mol'
60
116
  const MOL_VAL = '42'
@@ -62,19 +118,65 @@ test('httpStream', async t => {
62
118
  const headers = {} as { [idx: string]: string }
63
119
  headers[MOL_KEY] = MOL_VAL
64
120
 
65
- const res = await httpStream(URL, headers)
121
+ const res = await httpStream(`${host}/headers`, headers)
66
122
 
67
123
  const buffer = await streamToBuffer(res)
68
124
  const obj = JSON.parse(buffer.toString())
69
- t.equal(obj.headers[MOL_KEY], MOL_VAL, 'should send the header right')
125
+ // Node 会把 header name 规范成小写
126
+ t.equal(obj.headers[MOL_KEY.toLowerCase()], MOL_VAL, 'should send the header right')
70
127
  })
71
128
 
72
129
  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
78
- }
79
- t.equal(length, 4372373, 'should get data in chunks right')
130
+ const FILE_SIZE = 1024 * 1024 + 123 // > 默认 chunk size 触发分片逻辑
131
+ const content = Buffer.alloc(FILE_SIZE, 'A')
132
+
133
+ const server = createServer((req, res) => {
134
+ // HEAD:让 httpStream 判断是否支持 range + content-length
135
+ if (req.method === 'HEAD') {
136
+ res.writeHead(200, {
137
+ 'Accept-Ranges': 'bytes',
138
+ 'Content-Length': String(FILE_SIZE),
139
+ })
140
+ res.end()
141
+ return
142
+ }
143
+
144
+ const range = req.headers.range
145
+ if (range) {
146
+ const m = String(range).match(/bytes=(\d+)-(\d*)/)
147
+ if (!m) {
148
+ res.writeHead(416)
149
+ res.end()
150
+ return
151
+ }
152
+ const start = Number(m[1])
153
+ const end = m[2] ? Number(m[2]) : FILE_SIZE - 1
154
+ const chunk = content.subarray(start, end + 1)
155
+ res.writeHead(206, {
156
+ 'Accept-Ranges': 'bytes',
157
+ 'Content-Length': String(chunk.length),
158
+ 'Content-Range': `bytes ${start}-${end}/${FILE_SIZE}`,
159
+ })
160
+ res.end(chunk)
161
+ return
162
+ }
163
+
164
+ res.writeHead(200, {
165
+ 'Accept-Ranges': 'bytes',
166
+ 'Content-Length': String(FILE_SIZE),
167
+ })
168
+ res.end(content)
169
+ })
170
+
171
+ const host = await new Promise<string>((resolve) => {
172
+ server.listen(0, '127.0.0.1', () => {
173
+ const addr = server.address() as AddressInfo
174
+ resolve(`http://127.0.0.1:${addr.port}`)
175
+ })
176
+ })
177
+ t.teardown(() => { server.close() })
178
+
179
+ const res = await httpStream(`${host}/file`)
180
+ const buffer = await streamToBuffer(res)
181
+ t.equal(buffer.length, FILE_SIZE, 'should get data in chunks right')
80
182
  })
package/src/misc.ts CHANGED
@@ -2,20 +2,20 @@ import assert from 'assert'
2
2
  import { randomUUID } from 'crypto'
3
3
  import { once } from 'events'
4
4
  import { createReadStream, createWriteStream } from 'fs'
5
- import { rm } from 'fs/promises'
5
+ import { rm, stat } from 'fs/promises'
6
6
  import http, { RequestOptions } from 'http'
7
7
  import https from 'https'
8
8
  import { HttpsProxyAgent } from 'https-proxy-agent'
9
9
  import { tmpdir } from 'os'
10
10
  import { join } from 'path'
11
11
  import type { Readable } from 'stream'
12
+ import { Transform } from 'stream'
13
+ import { pipeline } from 'stream/promises'
12
14
  import { URL } from 'url'
13
15
 
14
16
  import {
15
- HTTP_CHUNK_SIZE,
16
17
  HTTP_REQUEST_TIMEOUT,
17
18
  HTTP_RESPONSE_TIMEOUT,
18
- NO_SLICE_DOWN,
19
19
  } from './config.js'
20
20
 
21
21
  const protocolMap: {
@@ -25,6 +25,7 @@ const protocolMap: {
25
25
  'https:': { agent: https.globalAgent, request: https.request },
26
26
  }
27
27
 
28
+ const noop = () => { }
28
29
  const unsupportedRangeDomains = new Set<string>()
29
30
 
30
31
  function getProtocol (protocol: string) {
@@ -53,8 +54,8 @@ export async function httpHeadHeader (url: string, headers: http.OutgoingHttpHea
53
54
  }
54
55
 
55
56
  const res = await fetch(url, {
56
- method: 'HEAD',
57
57
  headers,
58
+ method: 'HEAD',
58
59
  }, proxyUrl)
59
60
  res.destroy()
60
61
 
@@ -71,7 +72,8 @@ export async function httpHeadHeader (url: string, headers: http.OutgoingHttpHea
71
72
  throw new Error('302 found but no location!')
72
73
  }
73
74
 
74
- url = res.headers.location
75
+ // Location 可能是相对路径,需要以当前 url 作为 base 解析
76
+ url = new URL(res.headers.location, url).toString()
75
77
  }
76
78
  }
77
79
 
@@ -107,8 +109,14 @@ export async function httpStream (url: string, headers: http.OutgoingHttpHeaders
107
109
 
108
110
  const fileSize = Number(headHeaders['content-length'])
109
111
 
110
- if (!unsupportedRangeDomains.has(hostname)! && !NO_SLICE_DOWN && headHeaders['accept-ranges'] === 'bytes' && fileSize > HTTP_CHUNK_SIZE) {
111
- return await downloadFileInChunks(url, options, fileSize, HTTP_CHUNK_SIZE, proxyUrl)
112
+ // 运行时读取 env:方便测试/调用方动态调整
113
+ const noSliceDown = process.env['FILEBOX_NO_SLICE_DOWN'] === 'true'
114
+
115
+ // 检查服务器是否支持 range 请求
116
+ const supportsRange = headHeaders['accept-ranges'] === 'bytes'
117
+
118
+ if (!unsupportedRangeDomains.has(hostname) && !noSliceDown && supportsRange && fileSize > 0) {
119
+ return await downloadFileInChunks(url, options, proxyUrl)
112
120
  } else {
113
121
  return await fetch(url, options, proxyUrl)
114
122
  }
@@ -117,105 +125,283 @@ export async function httpStream (url: string, headers: http.OutgoingHttpHeaders
117
125
  async function fetch (url: string, options: http.RequestOptions, proxyUrl?: string): Promise<http.IncomingMessage> {
118
126
  const { protocol } = new URL(url)
119
127
  const { request, agent } = getProtocol(protocol)
128
+ const abortController = new AbortController()
129
+ const signal = abortController.signal
120
130
  const opts: http.RequestOptions = {
121
131
  agent,
122
132
  ...options,
133
+ signal,
123
134
  }
124
135
  setProxy(opts, proxyUrl)
136
+
125
137
  const req = request(url, opts)
126
- req
127
- .on('error', () => {
128
- req.destroy()
138
+ let res: http.IncomingMessage | undefined
139
+
140
+ // 兜底:任何时候 req.error 都不会变成 uncaughtException
141
+ const onReqError = (err: unknown) => {
142
+ const error = err instanceof Error ? err : new Error(String(err))
143
+ // 统一用 abort 中止请求:signal 已挂在 req 上,并且我们会把 abort(reason) 桥接到 res.destroy(...)
144
+ abortController.abort(error)
145
+ }
146
+ req.on('error', noop)
147
+ .on('error', onReqError)
148
+ .once('close', () => {
149
+ // close 后禁用 request timeout,避免定时器晚到触发 destroy -> error 无人监听
150
+ try { req.setTimeout(0) } catch { }
151
+ req.off('error', onReqError)
152
+ req.off('error', noop)
129
153
  })
154
+ // request timeout:只用于“拿到 response 之前”(连接/握手/首包)
130
155
  .setTimeout(HTTP_REQUEST_TIMEOUT, () => {
131
- req.emit('error', new Error(`FileBox: Http request timeout (${HTTP_REQUEST_TIMEOUT})!`))
156
+ // 已经拿到 response 时,不要再用 request timeout 误伤(会导致 aborted/ECONNRESET)
157
+ if (res) return
158
+ abortController.abort(new Error(`FileBox: Http request timeout (${HTTP_REQUEST_TIMEOUT})!`))
132
159
  })
133
160
  .end()
134
- const responseEvents = await once(req, 'response')
135
- const res = responseEvents[0] as http.IncomingMessage
136
- res
137
- .on('error', () => {
138
- res.destroy()
161
+
162
+ try {
163
+ const responseEvent = await once(req, 'response', { signal })
164
+ res = responseEvent[0] as http.IncomingMessage
165
+ // response 到来后清掉 request timeout,避免误伤长下载导致 aborted/ECONNRESET
166
+ try { req.setTimeout(0) } catch {}
167
+ // 必须尽早挂,避免 “response 刚到就 error/abort” 的竞态导致 uncaughtException
168
+ res.on('error', noop)
169
+ signal.throwIfAborted()
170
+ } catch (e) {
171
+ // once(...) 被 signal abort 时通常会抛 AbortError;优先抛出 abort(reason) 的真实原因
172
+ const reason = signal.reason as unknown
173
+ const err = reason instanceof Error
174
+ ? reason
175
+ : (e instanceof Error ? e : new Error(String(e)))
176
+ // 失败时尽量主动清理,避免 socket 悬挂(destroy 重复调用是安全的)
177
+ try { res?.destroy(err) } catch {}
178
+ try { req.destroy(err) } catch {}
179
+ throw err
180
+ }
181
+
182
+ const onAbort = () => {
183
+ const reason = signal.reason as unknown
184
+ res?.destroy(reason instanceof Error ? reason : new Error(String(reason)))
185
+ }
186
+ signal.addEventListener('abort', onAbort, { once: true })
187
+ res!
188
+ .once('end', () => { try { res!.setTimeout(0) } catch { } })
189
+ .once('close', () => {
190
+ // close 时做清理/兜底判断(尽力而为)
191
+ try { res!.setTimeout(0) } catch { }
192
+ if (!res!.complete && !res!.destroyed) {
193
+ // 有些场景不会 emit 'aborted',用 close + complete 兜底一次
194
+ res!.destroy(new Error('FileBox: Http response aborted!'))
195
+ }
196
+ signal.removeEventListener('abort', onAbort)
197
+ res!.off('error', noop)
139
198
  })
140
- if (res.socket) {
141
- res.setTimeout(HTTP_RESPONSE_TIMEOUT, () => {
142
- res.emit('error', new Error(`FileBox: Http response timeout (${HTTP_RESPONSE_TIMEOUT})!`))
199
+ .setTimeout(HTTP_RESPONSE_TIMEOUT, () => {
200
+ abortController.abort(new Error(`FileBox: Http response timeout (${HTTP_RESPONSE_TIMEOUT})!`))
143
201
  })
144
- }
145
- return res
202
+ return res!
203
+ }
204
+
205
+ function createSkipTransform (skipBytes: number): Transform {
206
+ let skipped = 0
207
+ return new Transform({
208
+ transform (chunk, _encoding, callback) {
209
+ if (skipped < skipBytes) {
210
+ const remaining = skipBytes - skipped
211
+ if (chunk.length <= remaining) {
212
+ // 整个 chunk 都需要跳过
213
+ skipped += chunk.length
214
+ callback()
215
+ return
216
+ } else {
217
+ // 跳过部分 chunk
218
+ skipped = skipBytes
219
+ callback(null, chunk.subarray(remaining))
220
+ return
221
+ }
222
+ }
223
+ // 已经跳过足够的字节,直接传递
224
+ callback(null, chunk)
225
+ },
226
+ })
146
227
  }
147
228
 
148
229
  async function downloadFileInChunks (
149
230
  url: string,
150
231
  options: http.RequestOptions,
151
- fileSize: number,
152
- chunkSize = HTTP_CHUNK_SIZE,
153
232
  proxyUrl?: string,
154
233
  ): Promise<Readable> {
155
234
  const tmpFile = join(tmpdir(), `filebox-${randomUUID()}`)
156
235
  const writeStream = createWriteStream(tmpFile)
236
+ const writeAbortController = new AbortController()
237
+ const signal = writeAbortController.signal
238
+ const onWriteError = (err: unknown) => {
239
+ writeAbortController.abort(err instanceof Error ? err : new Error(String(err)))
240
+ }
241
+ writeStream.once('error', onWriteError)
157
242
  const allowStatusCode = [ 200, 206 ]
158
243
  const requestBaseOptions: http.RequestOptions = {
159
244
  headers: {},
160
245
  ...options,
161
246
  }
162
- let chunkSeq = 0
247
+ // 预期文件大小(初始为 null,从首次 206 响应中获取)
248
+ let expectedTotal: number | null = null
163
249
  let start = 0
164
- let end = 0
165
250
  let downSize = 0
166
251
  let retries = 3
252
+ // 标识是否需要回退到非分片下载
253
+ let shouldFallback = false
254
+
255
+ do {
256
+ // 每次循环前检查文件实际大小,作为真实的下载进度
257
+ // 这样在重试时可以从实际写入的位置继续,避免数据重复
258
+ try {
259
+ const fileStats = await stat(tmpFile)
260
+ const actualSize = fileStats.size
261
+ if (actualSize > downSize) {
262
+ // 文件实际大小比记录的大,说明之前有部分写入
263
+ downSize = actualSize
264
+ start = actualSize
265
+ }
266
+ } catch (error) {
267
+ // 文件不存在或无法访问,使用当前的 downSize
268
+ }
167
269
 
168
- while (downSize < fileSize) {
169
- end = Math.min(start + chunkSize, fileSize - 1)
170
- const range = `bytes=${start}-${end}`
270
+ const range = `bytes=${start}-`
171
271
  const requestOptions = Object.assign({}, requestBaseOptions)
172
272
  assert(requestOptions.headers, 'Errors that should not happen: Invalid headers')
173
273
  ;(requestOptions.headers as http.OutgoingHttpHeaders)['Range'] = range
174
274
 
275
+ // 每次请求创建独立的 AbortController 来管理当前请求的生命周期
276
+ const requestAbortController = new AbortController()
277
+ requestOptions.signal = requestAbortController.signal
278
+
175
279
  try {
176
280
  const res = await fetch(url, requestOptions, proxyUrl)
177
281
  if (res.statusCode === 416) {
178
- unsupportedRangeDomains.add(new URL(url).hostname)
179
282
  // 某些云服务商对分片下载的支持可能不规范,需要保留一个回退的方式
180
- writeStream.close()
181
- await rm(tmpFile, { force: true })
182
- return await fetch(url, requestBaseOptions, proxyUrl)
283
+ shouldFallback = true
284
+ break
183
285
  }
184
286
  assert(allowStatusCode.includes(res.statusCode ?? 0), `Request failed with status code ${res.statusCode}`)
185
- assert(Number(res.headers['content-length']) > 0, 'Server returned 0 bytes of data')
186
- try {
187
- const { total } = parseContentRange(res.headers['content-range'] ?? '')
188
- if (total > 0 && total < fileSize) {
287
+ const contentLength = Number(res.headers['content-length'])
288
+ assert(contentLength > 0, 'Server returned 0 bytes of data')
289
+
290
+ // 206: 部分内容,继续分片下载
291
+ // 200: 完整内容,服务器不支持 range 或返回全部数据
292
+ if (res.statusCode === 206) {
293
+ // 206 响应必须包含有效的 Content-Range 头(RFC 7233)
294
+ const contentRange = res.headers['content-range']
295
+ if (!contentRange) {
296
+ // Content-Range 缺失,服务器不规范,回退到非分片下载
297
+ shouldFallback = true
298
+ break
299
+ }
300
+
301
+ let end: number
302
+ let total: number
303
+ let actualStart: number
304
+ try {
305
+ const parsed = parseContentRange(contentRange)
306
+ actualStart = parsed.start
307
+ end = parsed.end
308
+ total = parsed.total
309
+ } catch (error) {
310
+ // Content-Range 格式错误,服务器不规范,回退到非分片下载
311
+ shouldFallback = true
312
+ break
313
+ }
314
+
315
+ if (expectedTotal === null) {
316
+ // 首次获得文件总大小
189
317
  // 某些云服务商(如腾讯云)在 head 方法中返回的 size 是原图大小,但下载时返回的是压缩后的图片,会比原图小。
190
- // 这种在首次下载时虽然请求了原图大小的范围,可能比缩略图大,但会一次性返回完整的原图,而不是报错 416,通过修正 fileSize 跳出循环即可。
191
- fileSize = total
318
+ // 这种在首次下载时虽然请求了原图大小的范围,可能比缩略图大,但会一次性返回完整的原图,而不是报错 416,通过修正 expectedTotal 跳出循环即可。
319
+ expectedTotal = total
320
+ } else if (total !== expectedTotal) {
321
+ // 服务器返回的文件总大小出现了变化
322
+ throw new Error(`File size mismatch: expected ${expectedTotal}, but server returned ${total}`)
323
+ }
324
+
325
+ // 验证服务器返回的范围是否与请求匹配
326
+ if (actualStart !== start) {
327
+ if (actualStart > start) {
328
+ // 服务器跳过了部分数据,这是严重错误
329
+ throw new Error(`Range mismatch: requested start=${start}, but server returned start=${actualStart} (gap detected)`)
330
+ } else {
331
+ // actualStart < start: 服务器返回了重叠数据,需要跳过前面的字节
332
+ const skipBytes = start - actualStart
333
+ const skipTransform = createSkipTransform(skipBytes)
334
+ await pipeline(res, skipTransform, writeStream, { end: false, signal })
335
+ // 更新进度时使用我们请求的范围,而不是服务器返回的范围
336
+ downSize += end - actualStart + 1 - skipBytes
337
+ start = downSize
338
+ retries = 3 // 成功后重置重试次数
339
+ continue
340
+ }
341
+ }
342
+ // 使用 pipeline,但不关闭 writeStream(继续下载下一个分片)
343
+ await pipeline(res, writeStream, { end: false, signal })
344
+ // pipeline 成功后才更新下载进度
345
+ // end 是最后一个字节的索引,下次从 end+1 开始
346
+ downSize += end - start + 1
347
+ start = downSize
348
+ } else {
349
+ // 200: 服务器返回完整文件,不支持 range
350
+ if (start > 0) {
351
+ // 中途收到 200,服务器停止支持 range,标记并回退到普通下载
352
+ shouldFallback = true
353
+ break
192
354
  }
193
- } catch (error) {}
194
- for await (const chunk of res) {
195
- assert(Buffer.isBuffer(chunk))
196
- downSize += chunk.length
197
- writeStream.write(chunk)
355
+ // 首次请求返回 200,正常处理
356
+ await pipeline(res, writeStream, { signal })
357
+ downSize = contentLength
358
+ break
198
359
  }
199
- res.destroy()
360
+ // 成功后重置重试次数
361
+ retries = 3
200
362
  } catch (error) {
201
- const err = error as Error
363
+ const err = error instanceof Error ? error : new Error(String(error))
202
364
  if (--retries <= 0) {
203
- writeStream.close()
204
- void rm(tmpFile, { force: true })
365
+ writeStream.destroy()
366
+ await rm(tmpFile, { force: true })
205
367
  throw new Error(`Download file with chunk failed! ${err.message}`, { cause: err })
206
368
  }
369
+ // 失败后等待一小段时间再重试
370
+ await new Promise(resolve => setTimeout(resolve, 100))
371
+ } finally {
372
+ // 确保请求被清理(成功时也需要 abort 以释放资源)
373
+ requestAbortController.abort()
374
+ }
375
+ } while (expectedTotal === null || downSize < expectedTotal)
376
+
377
+ // 统一处理回退到非分片下载的情况
378
+ if (shouldFallback) {
379
+ unsupportedRangeDomains.add(new URL(url).hostname)
380
+ writeStream.destroy()
381
+ try {
382
+ await once(writeStream, 'close', { signal })
383
+ } catch {}
384
+ await rm(tmpFile, { force: true })
385
+ return await fetch(url, requestBaseOptions, proxyUrl)
386
+ }
387
+
388
+ writeStream.end()
389
+ try {
390
+ await once(writeStream, 'finish', { signal })
391
+ } catch (e) {
392
+ const reason = signal.reason as unknown
393
+ if (reason instanceof Error) {
394
+ throw reason
207
395
  }
208
- chunkSeq++
209
- start = downSize
396
+ throw e
397
+ } finally {
398
+ writeStream.off('error', onWriteError)
210
399
  }
211
- writeStream.close()
212
400
 
213
401
  const readStream = createReadStream(tmpFile)
214
- readStream
215
- .once('end', () => readStream.close())
216
- .once('close', () => {
217
- void rm(tmpFile, { force: true })
218
- })
402
+ readStream.once('close', () => {
403
+ rm(tmpFile, { force: true }).catch(() => {})
404
+ })
219
405
  return readStream
220
406
  }
221
407
 
@@ -234,15 +420,14 @@ function setProxy (options: RequestOptions, proxyUrl?: string): void {
234
420
  }
235
421
  }
236
422
 
237
-
238
423
  function parseContentRange (contentRange: string): { start: number, end: number, total: number } {
239
424
  const matches = contentRange.match(/bytes (\d+)-(\d+)\/(\d+)/)
240
425
  if (!matches) {
241
426
  throw new Error('Invalid content range')
242
427
  }
243
428
  return {
244
- start: Number(matches[1]),
245
429
  end: Number(matches[2]),
430
+ start: Number(matches[1]),
246
431
  total: Number(matches[3]),
247
432
  }
248
433
  }
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.19'
4
+ export const VERSION: string = '1.8.0'