@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.
- 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 +154 -27
- package/dist/cjs/src/misc.js.map +1 -1
- package/dist/cjs/src/misc.spec.js +101 -14
- package/dist/cjs/src/misc.spec.js.map +1 -1
- package/dist/cjs/src/version.js +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 +302 -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 +258 -0
- package/dist/cjs/tests/misc-error-handling.spec.js.map +1 -0
- 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 +155 -28
- package/dist/esm/src/misc.js.map +1 -1
- package/dist/esm/src/misc.spec.js +101 -14
- package/dist/esm/src/misc.spec.js.map +1 -1
- package/dist/esm/src/version.js +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 +300 -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 +256 -0
- package/dist/esm/tests/misc-error-handling.spec.js.map +1 -0
- package/package.json +10 -3
- package/src/file-box.spec.ts +34 -7
- package/src/file-box.ts +5 -5
- package/src/misc.spec.ts +111 -17
- package/src/misc.ts +140 -31
- package/src/version.ts +1 -1
package/src/file-box.spec.ts
CHANGED
|
@@ -2,13 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
import 'reflect-metadata'
|
|
4
4
|
|
|
5
|
-
import assert
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
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 }
|
|
11
|
-
import { FileBoxType }
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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,18 @@ test('httpHeaderToFileName', async t => {
|
|
|
54
84
|
})
|
|
55
85
|
|
|
56
86
|
test('httpStream', async t => {
|
|
57
|
-
const
|
|
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(
|
|
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
|
-
|
|
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
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
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
|
-
|
|
109
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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