@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/src/misc.ts CHANGED
@@ -1,10 +1,28 @@
1
- import http from 'http'
2
- import https from 'https'
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 { PassThrough, pipeline, Readable } from 'stream'
13
+ import { HTTP_CHUNK_SIZE, HTTP_REQUEST_TIMEOUT, HTTP_RESPONSE_TIMEOUT, NO_SLICE_DOWN } from './config.js'
6
14
 
7
- import { HTTP_CHUNK_SIZE, HTTP_TIMEOUT } from './config.js'
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 _headHeader(url)
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 parsedUrl = new URL(url)
111
-
112
- const protocol = parsedUrl.protocol
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 = Number(headHeaders['content-length'])
99
+ const fileSize = Number(headHeaders['content-length'])
137
100
 
138
- if (headHeaders['accept-ranges'] === 'bytes' && fileSize > HTTP_CHUNK_SIZE) {
139
- return await downloadFileInChunks(get, url, options, fileSize, HTTP_CHUNK_SIZE)
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 downloadFile(get, url, options)
104
+ return await fetch(url, options)
142
105
  }
143
106
  }
144
107
 
145
- async function downloadFile (
146
- get: typeof https.get,
147
- url: string,
148
- options: http.RequestOptions,
149
- ): Promise<Readable> {
150
- return new Promise<Readable>((resolve, reject) => {
151
- let res: http.IncomingMessage | null = null
152
- const req = get(url, options, (response) => {
153
- res = response
154
- resolve(res)
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
- req
158
- .on('error', reject)
159
- .setTimeout(HTTP_TIMEOUT, () => {
160
- const e = new Error(`FileBox: Http request timeout (${HTTP_TIMEOUT})!`)
161
- if (res) {
162
- res.emit('error', e)
163
- }
164
- req.emit('error', e)
165
- req.destroy()
166
- })
167
- .end()
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 ac = new AbortController()
179
- const stream = new PassThrough()
180
-
181
- const abortAc = () => {
182
- if (!ac.signal.aborted) {
183
- ac.abort()
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
- stream
188
- .once('close', abortAc)
189
- .once('error', abortAc)
190
-
191
- const chunksCount = Math.ceil(fileSize / chunkSize)
192
- let chunksDownloaded = 0
193
- let dataTotalSize = 0
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
- // console.info('doDownloadChunk() range:', range)
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 chunk = await downloadChunk(get, url, requestOptions, retries)
219
- if (chunk.errored) {
220
- throw new Error('chunk stream error')
221
- }
222
- if (chunk.closed) {
223
- throw new Error('chunk stream closed')
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
- if (chunk.destroyed) {
226
- throw new Error('chunk stream destroyed')
227
- }
228
- const buf = await streamToBuffer(chunk)
229
- stream.push(buf)
230
- chunksDownloaded++
231
- dataTotalSize += buf.length
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 doDownloadAllChunks = async function () {
246
- for (let i = 0; i < chunksCount; i++) {
247
- if (ac.signal.aborted) {
248
- return
249
- }
250
- await doDownloadChunk(i, 3)
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
- return new Promise<Buffer>((resolve, reject) => {
313
- const bufferList: Buffer[] = []
314
- stream.once('error', reject)
315
- stream.once('end', () => {
316
- const fullBuffer = Buffer.concat(bufferList)
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
@@ -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.4'
4
+ export const VERSION: string = '1.7.5'