@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.
- package/README.md +2 -2
- package/dist/cjs/src/config.d.ts +0 -1
- package/dist/cjs/src/config.d.ts.map +1 -1
- package/dist/cjs/src/config.js +1 -3
- package/dist/cjs/src/config.js.map +1 -1
- package/dist/cjs/src/file-box.d.ts.map +1 -1
- package/dist/cjs/src/file-box.js +2 -2
- package/dist/cjs/src/file-box.js.map +1 -1
- package/dist/cjs/src/file-box.spec.d.ts.map +1 -1
- package/dist/cjs/src/file-box.spec.js +26 -2
- package/dist/cjs/src/file-box.spec.js.map +1 -1
- package/dist/cjs/src/misc.d.ts.map +1 -1
- package/dist/cjs/src/misc.js +254 -50
- package/dist/cjs/src/misc.js.map +1 -1
- package/dist/cjs/src/misc.spec.js +105 -15
- package/dist/cjs/src/misc.spec.js.map +1 -1
- package/dist/cjs/src/version.d.ts.map +1 -1
- package/dist/cjs/src/version.js +1 -1
- package/dist/cjs/src/version.js.map +1 -1
- package/dist/cjs/tests/chunk-download.spec.d.ts +3 -0
- package/dist/cjs/tests/chunk-download.spec.d.ts.map +1 -0
- package/dist/cjs/tests/chunk-download.spec.js +318 -0
- package/dist/cjs/tests/chunk-download.spec.js.map +1 -0
- package/dist/cjs/tests/misc-error-handling.spec.d.ts +3 -0
- package/dist/cjs/tests/misc-error-handling.spec.d.ts.map +1 -0
- package/dist/cjs/tests/misc-error-handling.spec.js +352 -0
- package/dist/cjs/tests/misc-error-handling.spec.js.map +1 -0
- package/dist/cjs/tests/network-timeout.spec.js +15 -2
- package/dist/cjs/tests/network-timeout.spec.js.map +1 -1
- package/dist/esm/src/config.d.ts +0 -1
- package/dist/esm/src/config.d.ts.map +1 -1
- package/dist/esm/src/config.js +0 -2
- package/dist/esm/src/config.js.map +1 -1
- package/dist/esm/src/file-box.d.ts.map +1 -1
- package/dist/esm/src/file-box.js +2 -2
- package/dist/esm/src/file-box.js.map +1 -1
- package/dist/esm/src/file-box.spec.d.ts.map +1 -1
- package/dist/esm/src/file-box.spec.js +27 -3
- package/dist/esm/src/file-box.spec.js.map +1 -1
- package/dist/esm/src/misc.d.ts.map +1 -1
- package/dist/esm/src/misc.js +256 -52
- package/dist/esm/src/misc.js.map +1 -1
- package/dist/esm/src/misc.spec.js +105 -15
- package/dist/esm/src/misc.spec.js.map +1 -1
- package/dist/esm/src/version.d.ts.map +1 -1
- package/dist/esm/src/version.js +1 -1
- package/dist/esm/src/version.js.map +1 -1
- package/dist/esm/tests/chunk-download.spec.d.ts +3 -0
- package/dist/esm/tests/chunk-download.spec.d.ts.map +1 -0
- package/dist/esm/tests/chunk-download.spec.js +316 -0
- package/dist/esm/tests/chunk-download.spec.js.map +1 -0
- package/dist/esm/tests/misc-error-handling.spec.d.ts +3 -0
- package/dist/esm/tests/misc-error-handling.spec.d.ts.map +1 -0
- package/dist/esm/tests/misc-error-handling.spec.js +350 -0
- package/dist/esm/tests/misc-error-handling.spec.js.map +1 -0
- package/dist/esm/tests/network-timeout.spec.js +15 -2
- package/dist/esm/tests/network-timeout.spec.js.map +1 -1
- package/package.json +11 -4
- package/src/config.ts +0 -3
- package/src/file-box.spec.ts +34 -7
- package/src/file-box.ts +5 -5
- package/src/misc.spec.ts +120 -18
- package/src/misc.ts +242 -57
- 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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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,通过修正
|
|
191
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
writeStream.write(chunk)
|
|
355
|
+
// 首次请求返回 200,正常处理
|
|
356
|
+
await pipeline(res, writeStream, { signal })
|
|
357
|
+
downSize = contentLength
|
|
358
|
+
break
|
|
198
359
|
}
|
|
199
|
-
|
|
360
|
+
// 成功后重置重试次数
|
|
361
|
+
retries = 3
|
|
200
362
|
} catch (error) {
|
|
201
|
-
const err = error
|
|
363
|
+
const err = error instanceof Error ? error : new Error(String(error))
|
|
202
364
|
if (--retries <= 0) {
|
|
203
|
-
writeStream.
|
|
204
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
216
|
-
|
|
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