@juzi/file-box 1.7.19 → 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 +126 -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 +127 -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 +110 -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,7 @@ const protocolMap: {
|
|
|
25
24
|
'https:': { agent: https.globalAgent, request: https.request },
|
|
26
25
|
}
|
|
27
26
|
|
|
27
|
+
const noop = () => { }
|
|
28
28
|
const unsupportedRangeDomains = new Set<string>()
|
|
29
29
|
|
|
30
30
|
function getProtocol (protocol: string) {
|
|
@@ -53,8 +53,8 @@ export async function httpHeadHeader (url: string, headers: http.OutgoingHttpHea
|
|
|
53
53
|
}
|
|
54
54
|
|
|
55
55
|
const res = await fetch(url, {
|
|
56
|
-
method: 'HEAD',
|
|
57
56
|
headers,
|
|
57
|
+
method: 'HEAD',
|
|
58
58
|
}, proxyUrl)
|
|
59
59
|
res.destroy()
|
|
60
60
|
|
|
@@ -71,7 +71,8 @@ export async function httpHeadHeader (url: string, headers: http.OutgoingHttpHea
|
|
|
71
71
|
throw new Error('302 found but no location!')
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
url
|
|
74
|
+
// Location 可能是相对路径,需要以当前 url 作为 base 解析
|
|
75
|
+
url = new URL(res.headers.location, url).toString()
|
|
75
76
|
}
|
|
76
77
|
}
|
|
77
78
|
|
|
@@ -107,8 +108,12 @@ export async function httpStream (url: string, headers: http.OutgoingHttpHeaders
|
|
|
107
108
|
|
|
108
109
|
const fileSize = Number(headHeaders['content-length'])
|
|
109
110
|
|
|
110
|
-
|
|
111
|
-
|
|
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)
|
|
112
117
|
} else {
|
|
113
118
|
return await fetch(url, options, proxyUrl)
|
|
114
119
|
}
|
|
@@ -117,32 +122,81 @@ export async function httpStream (url: string, headers: http.OutgoingHttpHeaders
|
|
|
117
122
|
async function fetch (url: string, options: http.RequestOptions, proxyUrl?: string): Promise<http.IncomingMessage> {
|
|
118
123
|
const { protocol } = new URL(url)
|
|
119
124
|
const { request, agent } = getProtocol(protocol)
|
|
125
|
+
const abortController = new AbortController()
|
|
126
|
+
const signal = abortController.signal
|
|
120
127
|
const opts: http.RequestOptions = {
|
|
121
128
|
agent,
|
|
122
129
|
...options,
|
|
130
|
+
signal,
|
|
123
131
|
}
|
|
124
132
|
setProxy(opts, proxyUrl)
|
|
133
|
+
|
|
125
134
|
const req = request(url, opts)
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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)
|
|
129
150
|
})
|
|
151
|
+
// request timeout:只用于“拿到 response 之前”(连接/握手/首包)
|
|
130
152
|
.setTimeout(HTTP_REQUEST_TIMEOUT, () => {
|
|
131
|
-
|
|
153
|
+
// 已经拿到 response 时,不要再用 request timeout 误伤(会导致 aborted/ECONNRESET)
|
|
154
|
+
if (res) return
|
|
155
|
+
abortController.abort(new Error(`FileBox: Http request timeout (${HTTP_REQUEST_TIMEOUT})!`))
|
|
132
156
|
})
|
|
133
157
|
.end()
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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)
|
|
139
195
|
})
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
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})!`))
|
|
143
198
|
})
|
|
144
|
-
|
|
145
|
-
return res
|
|
199
|
+
return res!
|
|
146
200
|
}
|
|
147
201
|
|
|
148
202
|
async function downloadFileInChunks (
|
|
@@ -154,6 +208,12 @@ async function downloadFileInChunks (
|
|
|
154
208
|
): Promise<Readable> {
|
|
155
209
|
const tmpFile = join(tmpdir(), `filebox-${randomUUID()}`)
|
|
156
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)
|
|
157
217
|
const allowStatusCode = [ 200, 206 ]
|
|
158
218
|
const requestBaseOptions: http.RequestOptions = {
|
|
159
219
|
headers: {},
|
|
@@ -177,7 +237,10 @@ async function downloadFileInChunks (
|
|
|
177
237
|
if (res.statusCode === 416) {
|
|
178
238
|
unsupportedRangeDomains.add(new URL(url).hostname)
|
|
179
239
|
// 某些云服务商对分片下载的支持可能不规范,需要保留一个回退的方式
|
|
180
|
-
writeStream.
|
|
240
|
+
writeStream.destroy()
|
|
241
|
+
try {
|
|
242
|
+
await once(writeStream, 'close', { signal })
|
|
243
|
+
} catch {}
|
|
181
244
|
await rm(tmpFile, { force: true })
|
|
182
245
|
return await fetch(url, requestBaseOptions, proxyUrl)
|
|
183
246
|
}
|
|
@@ -194,13 +257,20 @@ async function downloadFileInChunks (
|
|
|
194
257
|
for await (const chunk of res) {
|
|
195
258
|
assert(Buffer.isBuffer(chunk))
|
|
196
259
|
downSize += chunk.length
|
|
197
|
-
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
|
+
}
|
|
198
268
|
}
|
|
199
269
|
res.destroy()
|
|
200
270
|
} catch (error) {
|
|
201
|
-
const err = error
|
|
271
|
+
const err = error instanceof Error ? error : new Error(String(error))
|
|
202
272
|
if (--retries <= 0) {
|
|
203
|
-
writeStream.
|
|
273
|
+
writeStream.destroy()
|
|
204
274
|
void rm(tmpFile, { force: true })
|
|
205
275
|
throw new Error(`Download file with chunk failed! ${err.message}`, { cause: err })
|
|
206
276
|
}
|
|
@@ -208,14 +278,24 @@ async function downloadFileInChunks (
|
|
|
208
278
|
chunkSeq++
|
|
209
279
|
start = downSize
|
|
210
280
|
}
|
|
211
|
-
|
|
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
|
+
}
|
|
212
294
|
|
|
213
295
|
const readStream = createReadStream(tmpFile)
|
|
214
|
-
readStream
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
void rm(tmpFile, { force: true })
|
|
218
|
-
})
|
|
296
|
+
readStream.once('close', () => {
|
|
297
|
+
rm(tmpFile, { force: true }).catch(() => {})
|
|
298
|
+
})
|
|
219
299
|
return readStream
|
|
220
300
|
}
|
|
221
301
|
|
|
@@ -234,15 +314,14 @@ function setProxy (options: RequestOptions, proxyUrl?: string): void {
|
|
|
234
314
|
}
|
|
235
315
|
}
|
|
236
316
|
|
|
237
|
-
|
|
238
317
|
function parseContentRange (contentRange: string): { start: number, end: number, total: number } {
|
|
239
318
|
const matches = contentRange.match(/bytes (\d+)-(\d+)\/(\d+)/)
|
|
240
319
|
if (!matches) {
|
|
241
320
|
throw new Error('Invalid content range')
|
|
242
321
|
}
|
|
243
322
|
return {
|
|
244
|
-
start: Number(matches[1]),
|
|
245
323
|
end: Number(matches[2]),
|
|
324
|
+
start: Number(matches[1]),
|
|
246
325
|
total: Number(matches[3]),
|
|
247
326
|
}
|
|
248
327
|
}
|
package/src/version.ts
CHANGED