@juzi/file-box 1.7.4 → 1.7.5
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 +5 -1
- package/dist/cjs/src/config.d.ts +3 -1
- package/dist/cjs/src/config.d.ts.map +1 -1
- package/dist/cjs/src/config.js +5 -2
- package/dist/cjs/src/config.js.map +1 -1
- package/dist/cjs/src/misc.d.ts +1 -1
- package/dist/cjs/src/misc.d.ts.map +1 -1
- package/dist/cjs/src/misc.js +96 -189
- package/dist/cjs/src/misc.js.map +1 -1
- package/dist/cjs/src/version.js +1 -1
- package/dist/cjs/tests/network-timeout.spec.js +14 -14
- package/dist/cjs/tests/network-timeout.spec.js.map +1 -1
- package/dist/esm/src/config.d.ts +3 -1
- package/dist/esm/src/config.d.ts.map +1 -1
- package/dist/esm/src/config.js +4 -1
- package/dist/esm/src/config.js.map +1 -1
- package/dist/esm/src/misc.d.ts +1 -1
- package/dist/esm/src/misc.d.ts.map +1 -1
- package/dist/esm/src/misc.js +97 -190
- package/dist/esm/src/misc.js.map +1 -1
- package/dist/esm/src/version.js +1 -1
- package/dist/esm/tests/network-timeout.spec.js +14 -14
- package/dist/esm/tests/network-timeout.spec.js.map +1 -1
- package/package.json +5 -2
- package/src/config.ts +6 -1
- package/src/misc.ts +106 -228
- package/src/version.ts +1 -1
package/src/misc.ts
CHANGED
|
@@ -1,10 +1,28 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
1
|
+
import assert from 'assert'
|
|
2
|
+
import { randomUUID } from 'crypto'
|
|
3
|
+
import { once } from 'events'
|
|
4
|
+
import { createReadStream, createWriteStream } from 'fs'
|
|
5
|
+
import { rm } from 'fs/promises'
|
|
6
|
+
import http from 'http'
|
|
7
|
+
import https from 'https'
|
|
8
|
+
import { tmpdir } from 'os'
|
|
9
|
+
import { join } from 'path'
|
|
10
|
+
import type { Readable } from 'stream'
|
|
3
11
|
import { URL } from 'url'
|
|
4
12
|
|
|
5
|
-
import {
|
|
13
|
+
import { HTTP_CHUNK_SIZE, HTTP_REQUEST_TIMEOUT, HTTP_RESPONSE_TIMEOUT, NO_SLICE_DOWN } from './config.js'
|
|
6
14
|
|
|
7
|
-
|
|
15
|
+
const protocolMap: {
|
|
16
|
+
[key: string]: { agent: http.Agent; request: typeof http.request }
|
|
17
|
+
} = {
|
|
18
|
+
'http:': { agent: http.globalAgent, request: http.request },
|
|
19
|
+
'https:': { agent: https.globalAgent, request: https.request },
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function getProtocol (protocol: string) {
|
|
23
|
+
assert(protocolMap[protocol], new Error('unknown protocol: ' + protocol))
|
|
24
|
+
return protocolMap[protocol]!
|
|
25
|
+
}
|
|
8
26
|
|
|
9
27
|
export function dataUrlToBase64 (dataUrl: string): string {
|
|
10
28
|
const dataList = dataUrl.split(',')
|
|
@@ -18,7 +36,6 @@ export function dataUrlToBase64 (dataUrl: string): string {
|
|
|
18
36
|
* @credit https://stackoverflow.com/a/43632171/1123955
|
|
19
37
|
*/
|
|
20
38
|
export async function httpHeadHeader (url: string): Promise<http.IncomingHttpHeaders> {
|
|
21
|
-
|
|
22
39
|
const originUrl = url
|
|
23
40
|
let REDIRECT_TTL = 7
|
|
24
41
|
|
|
@@ -27,7 +44,10 @@ export async function httpHeadHeader (url: string): Promise<http.IncomingHttpHea
|
|
|
27
44
|
throw new Error(`ttl expired! too many(>${REDIRECT_TTL}) 302 redirection.`)
|
|
28
45
|
}
|
|
29
46
|
|
|
30
|
-
const res = await
|
|
47
|
+
const res = await fetch(url, {
|
|
48
|
+
method: 'HEAD',
|
|
49
|
+
})
|
|
50
|
+
res.destroy()
|
|
31
51
|
|
|
32
52
|
if (!/^3/.test(String(res.statusCode))) {
|
|
33
53
|
if (originUrl !== url) {
|
|
@@ -44,44 +64,9 @@ export async function httpHeadHeader (url: string): Promise<http.IncomingHttpHea
|
|
|
44
64
|
|
|
45
65
|
url = res.headers.location
|
|
46
66
|
}
|
|
47
|
-
|
|
48
|
-
async function _headHeader (destUrl: string): Promise<http.IncomingMessage> {
|
|
49
|
-
const parsedUrl = new URL(destUrl)
|
|
50
|
-
const options = {
|
|
51
|
-
method : 'HEAD',
|
|
52
|
-
// method : 'GET',
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
let request: typeof http.request
|
|
56
|
-
|
|
57
|
-
if (parsedUrl.protocol === 'https:') {
|
|
58
|
-
request = https.request
|
|
59
|
-
} else if (parsedUrl.protocol === 'http:') {
|
|
60
|
-
request = http.request
|
|
61
|
-
} else {
|
|
62
|
-
throw new Error('unknown protocol: ' + parsedUrl.protocol)
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
return new Promise<http.IncomingMessage>((resolve, reject) => {
|
|
66
|
-
let res: undefined | http.IncomingMessage
|
|
67
|
-
const req = request(parsedUrl, options, (response) => {
|
|
68
|
-
res = response
|
|
69
|
-
resolve(res)
|
|
70
|
-
})
|
|
71
|
-
.on('error', reject)
|
|
72
|
-
.setTimeout(HTTP_TIMEOUT, () => {
|
|
73
|
-
const e = new Error(`FileBox: Http request timeout (${HTTP_TIMEOUT})!`)
|
|
74
|
-
req.emit('error', e)
|
|
75
|
-
req.destroy()
|
|
76
|
-
})
|
|
77
|
-
.end()
|
|
78
|
-
})
|
|
79
|
-
}
|
|
80
67
|
}
|
|
81
68
|
|
|
82
|
-
export function httpHeaderToFileName (
|
|
83
|
-
headers: http.IncomingHttpHeaders,
|
|
84
|
-
): null | string {
|
|
69
|
+
export function httpHeaderToFileName (headers: http.IncomingHttpHeaders): null | string {
|
|
85
70
|
const contentDisposition = headers['content-disposition']
|
|
86
71
|
|
|
87
72
|
if (!contentDisposition) {
|
|
@@ -98,224 +83,117 @@ export function httpHeaderToFileName (
|
|
|
98
83
|
return null
|
|
99
84
|
}
|
|
100
85
|
|
|
101
|
-
export async function httpStream (
|
|
102
|
-
url : string,
|
|
103
|
-
headers : http.OutgoingHttpHeaders = {},
|
|
104
|
-
): Promise<Readable> {
|
|
86
|
+
export async function httpStream (url: string, headers: http.OutgoingHttpHeaders = {}): Promise<Readable> {
|
|
105
87
|
const headHeaders = await httpHeadHeader(url)
|
|
106
88
|
if (headHeaders.location) {
|
|
107
89
|
url = headHeaders.location
|
|
90
|
+
const { protocol } = new URL(url)
|
|
91
|
+
getProtocol(protocol)
|
|
108
92
|
}
|
|
109
93
|
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
const options: http.RequestOptions = {}
|
|
115
|
-
|
|
116
|
-
let get: typeof https.get
|
|
117
|
-
|
|
118
|
-
if (!protocol) {
|
|
119
|
-
throw new Error('protocol is empty')
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
if (protocol.match(/^https:/i)) {
|
|
123
|
-
get = https.get
|
|
124
|
-
options.agent = https.globalAgent
|
|
125
|
-
} else if (protocol.match(/^http:/i)) {
|
|
126
|
-
get = http.get
|
|
127
|
-
options.agent = http.globalAgent
|
|
128
|
-
} else {
|
|
129
|
-
throw new Error('protocol unknown: ' + protocol)
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
options.headers = {
|
|
133
|
-
...headers,
|
|
94
|
+
const options: http.RequestOptions = {
|
|
95
|
+
headers: { ...headers },
|
|
96
|
+
method: 'GET',
|
|
134
97
|
}
|
|
135
98
|
|
|
136
|
-
const fileSize
|
|
99
|
+
const fileSize = Number(headHeaders['content-length'])
|
|
137
100
|
|
|
138
|
-
if (headHeaders['accept-ranges'] === 'bytes' && fileSize > HTTP_CHUNK_SIZE) {
|
|
139
|
-
return await downloadFileInChunks(
|
|
101
|
+
if (!NO_SLICE_DOWN && headHeaders['accept-ranges'] === 'bytes' && fileSize > HTTP_CHUNK_SIZE) {
|
|
102
|
+
return await downloadFileInChunks(url, options, fileSize, HTTP_CHUNK_SIZE)
|
|
140
103
|
} else {
|
|
141
|
-
return await
|
|
104
|
+
return await fetch(url, options)
|
|
142
105
|
}
|
|
143
106
|
}
|
|
144
107
|
|
|
145
|
-
async function
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
108
|
+
async function fetch (url: string, options: http.RequestOptions): Promise<http.IncomingMessage> {
|
|
109
|
+
const { protocol } = new URL(url)
|
|
110
|
+
const { request, agent } = getProtocol(protocol)
|
|
111
|
+
const opts: http.RequestOptions = {
|
|
112
|
+
agent,
|
|
113
|
+
...options,
|
|
114
|
+
}
|
|
115
|
+
const req = request(url, opts).end()
|
|
116
|
+
req
|
|
117
|
+
.on('error', () => {
|
|
118
|
+
req.destroy()
|
|
155
119
|
})
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
})
|
|
167
|
-
|
|
168
|
-
|
|
120
|
+
.setTimeout(HTTP_REQUEST_TIMEOUT, () => {
|
|
121
|
+
req.emit('error', new Error(`FileBox: Http request timeout (${HTTP_REQUEST_TIMEOUT})!`))
|
|
122
|
+
})
|
|
123
|
+
const responseEvents = await once(req, 'response')
|
|
124
|
+
const res = responseEvents[0] as http.IncomingMessage
|
|
125
|
+
res
|
|
126
|
+
.on('error', () => {
|
|
127
|
+
res.destroy()
|
|
128
|
+
})
|
|
129
|
+
.setTimeout(HTTP_RESPONSE_TIMEOUT, () => {
|
|
130
|
+
res.emit('error', new Error(`FileBox: Http response timeout (${HTTP_RESPONSE_TIMEOUT})!`))
|
|
131
|
+
})
|
|
132
|
+
return res
|
|
169
133
|
}
|
|
170
134
|
|
|
171
135
|
async function downloadFileInChunks (
|
|
172
|
-
get: typeof https.get,
|
|
173
136
|
url: string,
|
|
174
137
|
options: http.RequestOptions,
|
|
175
138
|
fileSize: number,
|
|
176
139
|
chunkSize = HTTP_CHUNK_SIZE,
|
|
177
140
|
): Promise<Readable> {
|
|
178
|
-
const
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
}
|
|
141
|
+
const tmpFile = join(tmpdir(), `filebox-${randomUUID()}`)
|
|
142
|
+
const writeStream = createWriteStream(tmpFile)
|
|
143
|
+
const allowStatusCode = [ 200, 206 ]
|
|
144
|
+
const requestBaseOptions: http.RequestOptions = {
|
|
145
|
+
headers: {},
|
|
146
|
+
...options,
|
|
185
147
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
const doDownloadChunk = async function (i: number, retries: number) {
|
|
196
|
-
const start = i * chunkSize
|
|
197
|
-
const end = Math.min((i + 1) * chunkSize - 1, fileSize - 1)
|
|
148
|
+
let chunkSeq = 0
|
|
149
|
+
let start = 0
|
|
150
|
+
let end = 0
|
|
151
|
+
let downSize = 0
|
|
152
|
+
let retries = 3
|
|
153
|
+
|
|
154
|
+
while (downSize < fileSize) {
|
|
155
|
+
end = Math.min(start + chunkSize, fileSize - 1)
|
|
198
156
|
const range = `bytes=${start}-${end}`
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
if (ac.signal.aborted) {
|
|
203
|
-
stream.destroy(new Error('Signal aborted.'))
|
|
204
|
-
return
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
const requestOptions: http.RequestOptions = {
|
|
208
|
-
...options,
|
|
209
|
-
signal: ac.signal,
|
|
210
|
-
timeout: HTTP_TIMEOUT,
|
|
211
|
-
}
|
|
212
|
-
if (!requestOptions.headers) {
|
|
213
|
-
requestOptions.headers = {}
|
|
214
|
-
}
|
|
157
|
+
const requestOptions = Object.assign({}, requestBaseOptions)
|
|
158
|
+
assert(requestOptions.headers, 'Errors that should not happen: Invalid headers')
|
|
215
159
|
requestOptions.headers['Range'] = range
|
|
216
160
|
|
|
217
161
|
try {
|
|
218
|
-
const
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
162
|
+
const res = await fetch(url, requestOptions)
|
|
163
|
+
assert(allowStatusCode.includes(res.statusCode ?? 0), `Request failed with status code ${res.statusCode}`)
|
|
164
|
+
assert(Number(res.headers['content-length']) > 0, 'Server returned 0 bytes of data')
|
|
165
|
+
for await (const chunk of res) {
|
|
166
|
+
assert(Buffer.isBuffer(chunk))
|
|
167
|
+
downSize += chunk.length
|
|
168
|
+
writeStream.write(chunk)
|
|
224
169
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
if (chunksDownloaded === chunksCount || dataTotalSize >= fileSize) {
|
|
234
|
-
stream.push(null)
|
|
235
|
-
}
|
|
236
|
-
} catch (err) {
|
|
237
|
-
if (retries === 0) {
|
|
238
|
-
stream.emit('error', err)
|
|
239
|
-
} else {
|
|
240
|
-
await doDownloadChunk(i, retries - 1)
|
|
170
|
+
res.destroy()
|
|
171
|
+
} catch (error) {
|
|
172
|
+
const err = error as Error
|
|
173
|
+
if (--retries <= 0) {
|
|
174
|
+
void rm(tmpFile, { force: true })
|
|
175
|
+
writeStream.close()
|
|
176
|
+
throw new Error(`Download file with chunk failed! ${err.message}`, { cause: err })
|
|
241
177
|
}
|
|
242
178
|
}
|
|
179
|
+
chunkSeq++
|
|
180
|
+
start = downSize
|
|
243
181
|
}
|
|
182
|
+
writeStream.close()
|
|
244
183
|
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
void doDownloadAllChunks().catch((e) => {
|
|
255
|
-
stream.emit('error', e)
|
|
256
|
-
})
|
|
257
|
-
|
|
258
|
-
return stream
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
async function downloadChunk (
|
|
262
|
-
get: typeof https.get,
|
|
263
|
-
url: string,
|
|
264
|
-
requestOptions: http.RequestOptions,
|
|
265
|
-
retries: number,
|
|
266
|
-
): Promise<Readable> {
|
|
267
|
-
return new Promise<Readable>((resolve, reject) => {
|
|
268
|
-
const doRequest = (attempt: number) => {
|
|
269
|
-
let resolved = false
|
|
270
|
-
const req = get(url, requestOptions, (res) => {
|
|
271
|
-
const statusCode = res.statusCode ?? 0
|
|
272
|
-
// console.info('downloadChunk(%d) statusCode: %d rsp.headers: %o', attempt, statusCode, res.headers)
|
|
273
|
-
|
|
274
|
-
if (statusCode < 200 || statusCode >= 300) {
|
|
275
|
-
if (attempt < retries) {
|
|
276
|
-
void doRequest(attempt + 1)
|
|
277
|
-
} else {
|
|
278
|
-
reject(new Error(`Request failed with status code ${res.statusCode}`))
|
|
279
|
-
}
|
|
280
|
-
return
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
const stream = pipeline(res, new PassThrough(), () => {})
|
|
284
|
-
resolve(stream)
|
|
285
|
-
resolved = true
|
|
286
|
-
})
|
|
287
|
-
|
|
288
|
-
req
|
|
289
|
-
.once('error', (err) => {
|
|
290
|
-
if (resolved) {
|
|
291
|
-
return
|
|
292
|
-
}
|
|
293
|
-
// console.error('downloadChunk(%d) req error:', attempt, err)
|
|
294
|
-
if (attempt < retries) {
|
|
295
|
-
void doRequest(attempt + 1)
|
|
296
|
-
} else {
|
|
297
|
-
reject(err)
|
|
298
|
-
}
|
|
299
|
-
})
|
|
300
|
-
.setTimeout(HTTP_TIMEOUT, () => {
|
|
301
|
-
const e = new Error(`FileBox: Http request timeout (${HTTP_TIMEOUT})!`)
|
|
302
|
-
req.emit('error', e)
|
|
303
|
-
req.destroy()
|
|
304
|
-
})
|
|
305
|
-
.end()
|
|
306
|
-
}
|
|
307
|
-
void doRequest(0)
|
|
308
|
-
})
|
|
184
|
+
const readStream = createReadStream(tmpFile)
|
|
185
|
+
readStream
|
|
186
|
+
.once('end', () => readStream.close())
|
|
187
|
+
.once('close', () => {
|
|
188
|
+
void rm(tmpFile, { force: true })
|
|
189
|
+
})
|
|
190
|
+
return readStream
|
|
309
191
|
}
|
|
310
192
|
|
|
311
193
|
export async function streamToBuffer (stream: Readable): Promise<Buffer> {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
resolve(fullBuffer)
|
|
318
|
-
})
|
|
319
|
-
stream.on('data', (buffer) => bufferList.push(buffer))
|
|
320
|
-
})
|
|
194
|
+
const chunks: Buffer[] = []
|
|
195
|
+
for await (const chunk of stream) {
|
|
196
|
+
chunks.push(chunk)
|
|
197
|
+
}
|
|
198
|
+
return Buffer.concat(chunks)
|
|
321
199
|
}
|
package/src/version.ts
CHANGED