@juzi/file-box 1.8.0 → 1.8.1
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 +3 -1
- package/dist/cjs/src/config.d.ts +5 -4
- package/dist/cjs/src/config.d.ts.map +1 -1
- package/dist/cjs/src/config.js +7 -7
- package/dist/cjs/src/config.js.map +1 -1
- package/dist/cjs/src/file-box.js +1 -1
- package/dist/cjs/src/file-box.js.map +1 -1
- package/dist/cjs/src/misc.d.ts.map +1 -1
- package/dist/cjs/src/misc.js +96 -85
- package/dist/cjs/src/misc.js.map +1 -1
- package/dist/cjs/src/misc.spec.js +6 -0
- 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.js +12 -56
- package/dist/cjs/tests/chunk-download.spec.js.map +1 -1
- package/dist/cjs/tests/misc-error-handling.spec.js +36 -26
- package/dist/cjs/tests/misc-error-handling.spec.js.map +1 -1
- package/dist/cjs/tests/network-timeout.spec.js +101 -118
- package/dist/cjs/tests/network-timeout.spec.js.map +1 -1
- package/dist/esm/src/config.d.ts +5 -4
- package/dist/esm/src/config.d.ts.map +1 -1
- package/dist/esm/src/config.js +6 -6
- package/dist/esm/src/config.js.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/misc.d.ts.map +1 -1
- package/dist/esm/src/misc.js +97 -86
- package/dist/esm/src/misc.js.map +1 -1
- package/dist/esm/src/misc.spec.js +6 -0
- 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.js +12 -56
- package/dist/esm/tests/chunk-download.spec.js.map +1 -1
- package/dist/esm/tests/misc-error-handling.spec.js +36 -26
- package/dist/esm/tests/misc-error-handling.spec.js.map +1 -1
- package/dist/esm/tests/network-timeout.spec.js +103 -120
- package/dist/esm/tests/network-timeout.spec.js.map +1 -1
- package/package.json +1 -1
- package/src/config.ts +6 -9
- package/src/file-box.ts +2 -2
- package/src/misc.spec.ts +7 -0
- package/src/misc.ts +108 -89
- package/src/version.ts +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"network-timeout.spec.js","sourceRoot":"","sources":["../../../tests/network-timeout.spec.ts"],"names":[],"mappings":";AAEA,OAAO,EAAE,YAAY,EAAE,MAAM,MAAM,CAAA;AAEnC,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAA;AAC5C,OAAO,EAAE,
|
|
1
|
+
{"version":3,"file":"network-timeout.spec.js","sourceRoot":"","sources":["../../../tests/network-timeout.spec.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
package/src/config.ts
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
1
|
/// <reference path="./typings.d.ts" />
|
|
2
2
|
export { VERSION } from './version.js'
|
|
3
3
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
export const NO_SLICE_DOWN = process.env['FILEBOX_NO_SLICE_DOWN'] === 'true'
|
|
11
|
-
|
|
12
|
-
export const READY_RETRY = Number(process.env['FILE_BOX_READY_RETRY']) || 3
|
|
4
|
+
// 导出可变配置对象,支持测试时动态修改
|
|
5
|
+
export const CONFIG = {
|
|
6
|
+
HTTP_REQUEST_TIMEOUT: Number(process.env['FILEBOX_HTTP_REQUEST_TIMEOUT']) || 10 * 1000,
|
|
7
|
+
HTTP_RESPONSE_TIMEOUT: Number(process.env['FILEBOX_HTTP_RESPONSE_TIMEOUT'] ?? process.env['FILEBOX_HTTP_TIMEOUT']) || 60 * 1000,
|
|
8
|
+
READY_RETRY: Number(process.env['FILEBOX_READY_RETRY'] ?? process.env['FILE_BOX_READY_RETRY']) || 3,
|
|
9
|
+
}
|
package/src/file-box.ts
CHANGED
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
} from 'clone-class'
|
|
26
26
|
|
|
27
27
|
import {
|
|
28
|
-
|
|
28
|
+
CONFIG,
|
|
29
29
|
VERSION,
|
|
30
30
|
} from './config.js'
|
|
31
31
|
import {
|
|
@@ -637,7 +637,7 @@ class FileBox implements Pipeable, FileBoxInterface {
|
|
|
637
637
|
break
|
|
638
638
|
} catch (e) {
|
|
639
639
|
tryCount++
|
|
640
|
-
if (tryCount >= READY_RETRY) {
|
|
640
|
+
if (tryCount >= CONFIG.READY_RETRY) {
|
|
641
641
|
throw e
|
|
642
642
|
}
|
|
643
643
|
}
|
package/src/misc.spec.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { createServer } from 'http'
|
|
|
5
5
|
import type { AddressInfo } from 'net'
|
|
6
6
|
import { test } from 'tstest'
|
|
7
7
|
|
|
8
|
+
import { CONFIG } from './config.js'
|
|
8
9
|
import {
|
|
9
10
|
dataUrlToBase64,
|
|
10
11
|
httpHeaderToFileName,
|
|
@@ -13,6 +14,10 @@ import {
|
|
|
13
14
|
streamToBuffer,
|
|
14
15
|
} from './misc.js'
|
|
15
16
|
|
|
17
|
+
// 设置短超时用于测试
|
|
18
|
+
CONFIG.HTTP_REQUEST_TIMEOUT = 1000
|
|
19
|
+
CONFIG.HTTP_RESPONSE_TIMEOUT = 1000
|
|
20
|
+
|
|
16
21
|
test('dataUrl to base64', async t => {
|
|
17
22
|
const base64 = [
|
|
18
23
|
'R0lGODlhEAAQAMQAAORHHOVSKudfOulrSOp3WOyDZu6QdvCchPGolfO0o/XBs/fNwfjZ0frl',
|
|
@@ -97,6 +102,8 @@ test('httpStream', async t => {
|
|
|
97
102
|
return
|
|
98
103
|
}
|
|
99
104
|
|
|
105
|
+
// This server doesn't support Range, always return 200 with full content
|
|
106
|
+
// (ignoring any Range header)
|
|
100
107
|
res.writeHead(200, {
|
|
101
108
|
'Content-Length': String(content.length),
|
|
102
109
|
'Content-Type': 'application/json',
|
package/src/misc.ts
CHANGED
|
@@ -11,12 +11,10 @@ import { join } from 'path'
|
|
|
11
11
|
import type { Readable } from 'stream'
|
|
12
12
|
import { Transform } from 'stream'
|
|
13
13
|
import { pipeline } from 'stream/promises'
|
|
14
|
+
import { setTimeout } from 'timers/promises'
|
|
14
15
|
import { URL } from 'url'
|
|
15
16
|
|
|
16
|
-
import {
|
|
17
|
-
HTTP_REQUEST_TIMEOUT,
|
|
18
|
-
HTTP_RESPONSE_TIMEOUT,
|
|
19
|
-
} from './config.js'
|
|
17
|
+
import { CONFIG } from './config.js'
|
|
20
18
|
|
|
21
19
|
const protocolMap: {
|
|
22
20
|
[key: string]: { agent: http.Agent; request: typeof http.request }
|
|
@@ -28,6 +26,16 @@ const protocolMap: {
|
|
|
28
26
|
const noop = () => { }
|
|
29
27
|
const unsupportedRangeDomains = new Set<string>()
|
|
30
28
|
|
|
29
|
+
// 自定义 Error:标记需要回退到非分片下载
|
|
30
|
+
class FallbackError extends Error {
|
|
31
|
+
|
|
32
|
+
constructor (reason: string) {
|
|
33
|
+
super(`Fallback required: ${reason}`)
|
|
34
|
+
this.name = 'FallbackError'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
}
|
|
38
|
+
|
|
31
39
|
function getProtocol (protocol: string) {
|
|
32
40
|
assert(protocolMap[protocol], new Error('unknown protocol: ' + protocol))
|
|
33
41
|
return protocolMap[protocol]!
|
|
@@ -66,8 +74,6 @@ export async function httpHeadHeader (url: string, headers: http.OutgoingHttpHea
|
|
|
66
74
|
return res.headers
|
|
67
75
|
}
|
|
68
76
|
|
|
69
|
-
// console.log('302 found for ' + url)
|
|
70
|
-
|
|
71
77
|
if (!res.headers.location) {
|
|
72
78
|
throw new Error('302 found but no location!')
|
|
73
79
|
}
|
|
@@ -99,7 +105,7 @@ export async function httpStream (url: string, headers: http.OutgoingHttpHeaders
|
|
|
99
105
|
if (headHeaders.location) {
|
|
100
106
|
url = headHeaders.location
|
|
101
107
|
}
|
|
102
|
-
const { protocol, hostname } = new URL(url)
|
|
108
|
+
const { protocol, hostname, port } = new URL(url)
|
|
103
109
|
getProtocol(protocol)
|
|
104
110
|
|
|
105
111
|
const options: http.RequestOptions = {
|
|
@@ -107,19 +113,17 @@ export async function httpStream (url: string, headers: http.OutgoingHttpHeaders
|
|
|
107
113
|
method: 'GET',
|
|
108
114
|
}
|
|
109
115
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
return await fetch(url, options, proxyUrl)
|
|
122
|
-
}
|
|
116
|
+
// 使用 hostname:port 作为域名标识,避免不同端口的服务互相影响
|
|
117
|
+
const defaultPort = protocol === 'https:' ? '443' : '80'
|
|
118
|
+
const hostKey = `${hostname}:${port || defaultPort}`
|
|
119
|
+
|
|
120
|
+
// 直接尝试分片下载,不检查 Accept-Ranges 和 fileSize
|
|
121
|
+
// 原因:
|
|
122
|
+
// 1. 有些服务器 HEAD 不返回 Accept-Ranges 但实际支持分片
|
|
123
|
+
// 2. 有些服务器 HEAD 返回 fileSize=0 但实际支持分片
|
|
124
|
+
// downloadFileInChunks 内部有完善的回退机制处理不支持的情况
|
|
125
|
+
const result = await downloadFileInChunks(url, options, proxyUrl, hostKey)
|
|
126
|
+
return result
|
|
123
127
|
}
|
|
124
128
|
|
|
125
129
|
async function fetch (url: string, options: http.RequestOptions, proxyUrl?: string): Promise<http.IncomingMessage> {
|
|
@@ -152,10 +156,10 @@ async function fetch (url: string, options: http.RequestOptions, proxyUrl?: stri
|
|
|
152
156
|
req.off('error', noop)
|
|
153
157
|
})
|
|
154
158
|
// request timeout:只用于“拿到 response 之前”(连接/握手/首包)
|
|
155
|
-
.setTimeout(HTTP_REQUEST_TIMEOUT, () => {
|
|
159
|
+
.setTimeout(CONFIG.HTTP_REQUEST_TIMEOUT, () => {
|
|
156
160
|
// 已经拿到 response 时,不要再用 request timeout 误伤(会导致 aborted/ECONNRESET)
|
|
157
161
|
if (res) return
|
|
158
|
-
abortController.abort(new Error(`FileBox: Http request timeout (${HTTP_REQUEST_TIMEOUT})!`))
|
|
162
|
+
abortController.abort(new Error(`FileBox: Http request timeout (${CONFIG.HTTP_REQUEST_TIMEOUT})!`))
|
|
159
163
|
})
|
|
160
164
|
.end()
|
|
161
165
|
|
|
@@ -168,7 +172,6 @@ async function fetch (url: string, options: http.RequestOptions, proxyUrl?: stri
|
|
|
168
172
|
res.on('error', noop)
|
|
169
173
|
signal.throwIfAborted()
|
|
170
174
|
} catch (e) {
|
|
171
|
-
// once(...) 被 signal abort 时通常会抛 AbortError;优先抛出 abort(reason) 的真实原因
|
|
172
175
|
const reason = signal.reason as unknown
|
|
173
176
|
const err = reason instanceof Error
|
|
174
177
|
? reason
|
|
@@ -196,8 +199,8 @@ async function fetch (url: string, options: http.RequestOptions, proxyUrl?: stri
|
|
|
196
199
|
signal.removeEventListener('abort', onAbort)
|
|
197
200
|
res!.off('error', noop)
|
|
198
201
|
})
|
|
199
|
-
.setTimeout(HTTP_RESPONSE_TIMEOUT, () => {
|
|
200
|
-
abortController.abort(new Error(`FileBox: Http response timeout (${HTTP_RESPONSE_TIMEOUT})!`))
|
|
202
|
+
.setTimeout(CONFIG.HTTP_RESPONSE_TIMEOUT, () => {
|
|
203
|
+
abortController.abort(new Error(`FileBox: Http response timeout (${CONFIG.HTTP_RESPONSE_TIMEOUT})!`))
|
|
201
204
|
})
|
|
202
205
|
return res!
|
|
203
206
|
}
|
|
@@ -229,10 +232,11 @@ function createSkipTransform (skipBytes: number): Transform {
|
|
|
229
232
|
async function downloadFileInChunks (
|
|
230
233
|
url: string,
|
|
231
234
|
options: http.RequestOptions,
|
|
232
|
-
proxyUrl
|
|
235
|
+
proxyUrl: string | undefined,
|
|
236
|
+
hostname: string,
|
|
233
237
|
): Promise<Readable> {
|
|
234
238
|
const tmpFile = join(tmpdir(), `filebox-${randomUUID()}`)
|
|
235
|
-
|
|
239
|
+
let writeStream = createWriteStream(tmpFile)
|
|
236
240
|
const writeAbortController = new AbortController()
|
|
237
241
|
const signal = writeAbortController.signal
|
|
238
242
|
const onWriteError = (err: unknown) => {
|
|
@@ -249,43 +253,42 @@ async function downloadFileInChunks (
|
|
|
249
253
|
let start = 0
|
|
250
254
|
let downSize = 0
|
|
251
255
|
let retries = 3
|
|
252
|
-
//
|
|
253
|
-
let
|
|
256
|
+
// 控制是否使用 Range 请求(根据域名黑名单初始化)
|
|
257
|
+
let useRange = !unsupportedRangeDomains.has(hostname)
|
|
258
|
+
let useChunked = false
|
|
254
259
|
|
|
255
260
|
do {
|
|
256
261
|
// 每次循环前检查文件实际大小,作为真实的下载进度
|
|
257
262
|
// 这样在重试时可以从实际写入的位置继续,避免数据重复
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
downSize = actualSize
|
|
264
|
-
start = actualSize
|
|
265
|
-
}
|
|
266
|
-
} catch (error) {
|
|
267
|
-
// 文件不存在或无法访问,使用当前的 downSize
|
|
263
|
+
const fileStats = await stat(tmpFile).then(stats => stats.size).catch(() => 0)
|
|
264
|
+
if (fileStats !== downSize) {
|
|
265
|
+
// 文件实际大小与记录的不一致,使用实际大小
|
|
266
|
+
downSize = fileStats
|
|
267
|
+
start = fileStats
|
|
268
268
|
}
|
|
269
269
|
|
|
270
|
-
const range = `bytes=${start}-`
|
|
271
270
|
const requestOptions = Object.assign({}, requestBaseOptions)
|
|
272
271
|
assert(requestOptions.headers, 'Errors that should not happen: Invalid headers')
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
//
|
|
276
|
-
|
|
277
|
-
|
|
272
|
+
const headers = requestOptions.headers as http.OutgoingHttpHeaders
|
|
273
|
+
|
|
274
|
+
// 根据 useRange flag 决定是否添加 Range header
|
|
275
|
+
if (useRange) {
|
|
276
|
+
const range = `bytes=${start}-`
|
|
277
|
+
headers['Range'] = range
|
|
278
|
+
} else {
|
|
279
|
+
delete headers['Range']
|
|
280
|
+
}
|
|
278
281
|
|
|
282
|
+
let res: http.IncomingMessage
|
|
279
283
|
try {
|
|
280
|
-
|
|
284
|
+
res = await fetch(url, requestOptions, proxyUrl)
|
|
281
285
|
if (res.statusCode === 416) {
|
|
282
|
-
//
|
|
283
|
-
|
|
284
|
-
break
|
|
286
|
+
// 416: Range Not Satisfiable,服务器不支持此范围或文件大小不匹配
|
|
287
|
+
throw new FallbackError('416 Range Not Satisfiable')
|
|
285
288
|
}
|
|
286
289
|
assert(allowStatusCode.includes(res.statusCode ?? 0), `Request failed with status code ${res.statusCode}`)
|
|
287
|
-
const contentLength = Number(res.headers['content-length'])
|
|
288
|
-
assert(contentLength
|
|
290
|
+
const contentLength = Number(res.headers['content-length']) || 0
|
|
291
|
+
assert(contentLength >= 0, `Server returned ${contentLength} bytes of data`)
|
|
289
292
|
|
|
290
293
|
// 206: 部分内容,继续分片下载
|
|
291
294
|
// 200: 完整内容,服务器不支持 range 或返回全部数据
|
|
@@ -294,8 +297,7 @@ async function downloadFileInChunks (
|
|
|
294
297
|
const contentRange = res.headers['content-range']
|
|
295
298
|
if (!contentRange) {
|
|
296
299
|
// Content-Range 缺失,服务器不规范,回退到非分片下载
|
|
297
|
-
|
|
298
|
-
break
|
|
300
|
+
throw new FallbackError('Missing Content-Range header')
|
|
299
301
|
}
|
|
300
302
|
|
|
301
303
|
let end: number
|
|
@@ -308,8 +310,7 @@ async function downloadFileInChunks (
|
|
|
308
310
|
total = parsed.total
|
|
309
311
|
} catch (error) {
|
|
310
312
|
// Content-Range 格式错误,服务器不规范,回退到非分片下载
|
|
311
|
-
|
|
312
|
-
break
|
|
313
|
+
throw new FallbackError(`Invalid Content-Range: ${contentRange}`)
|
|
313
314
|
}
|
|
314
315
|
|
|
315
316
|
if (expectedTotal === null) {
|
|
@@ -322,6 +323,9 @@ async function downloadFileInChunks (
|
|
|
322
323
|
throw new Error(`File size mismatch: expected ${expectedTotal}, but server returned ${total}`)
|
|
323
324
|
}
|
|
324
325
|
|
|
326
|
+
// 标记使用了分片下载
|
|
327
|
+
useChunked = true
|
|
328
|
+
|
|
325
329
|
// 验证服务器返回的范围是否与请求匹配
|
|
326
330
|
if (actualStart !== start) {
|
|
327
331
|
if (actualStart > start) {
|
|
@@ -345,58 +349,73 @@ async function downloadFileInChunks (
|
|
|
345
349
|
// end 是最后一个字节的索引,下次从 end+1 开始
|
|
346
350
|
downSize += end - start + 1
|
|
347
351
|
start = downSize
|
|
348
|
-
} else {
|
|
349
|
-
// 200:
|
|
350
|
-
if (start > 0) {
|
|
351
|
-
//
|
|
352
|
-
|
|
353
|
-
|
|
352
|
+
} else if (res.statusCode === 200) {
|
|
353
|
+
// 200: 服务器返回完整文件
|
|
354
|
+
if (useChunked || start > 0) {
|
|
355
|
+
// 之前以分片模式下载过数据
|
|
356
|
+
writeStream.destroy()
|
|
357
|
+
await rm(tmpFile, { force: true }).catch(() => {})
|
|
358
|
+
writeStream = createWriteStream(tmpFile)
|
|
359
|
+
writeStream.on('error', onWriteError)
|
|
360
|
+
start = 0
|
|
361
|
+
downSize = 0
|
|
354
362
|
}
|
|
355
|
-
|
|
356
|
-
|
|
363
|
+
|
|
364
|
+
// 处理完整文件响应
|
|
365
|
+
expectedTotal = contentLength
|
|
366
|
+
await pipeline(res, writeStream, { end: false, signal })
|
|
357
367
|
downSize = contentLength
|
|
358
368
|
break
|
|
369
|
+
} else {
|
|
370
|
+
throw new Error(`Unexpected status code: ${res.statusCode}`)
|
|
359
371
|
}
|
|
360
372
|
// 成功后重置重试次数
|
|
361
373
|
retries = 3
|
|
362
374
|
} catch (error) {
|
|
375
|
+
if (error instanceof FallbackError) {
|
|
376
|
+
// 回退逻辑:记录域名、重置状态,在下次循环中以非 range 模式请求
|
|
377
|
+
unsupportedRangeDomains.add(hostname)
|
|
378
|
+
|
|
379
|
+
// 关闭当前写入流
|
|
380
|
+
writeStream.destroy()
|
|
381
|
+
await rm(tmpFile, { force: true }).catch(() => {})
|
|
382
|
+
|
|
383
|
+
// 检查是否已经是非 Range 模式,避免无限回退
|
|
384
|
+
if (!useRange) {
|
|
385
|
+
// 已经是非 Range 模式还失败,无法继续
|
|
386
|
+
throw new Error(`Download failed even in non-chunked mode: ${(error as Error).message}`)
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
writeStream = createWriteStream(tmpFile)
|
|
390
|
+
writeStream.once('error', onWriteError)
|
|
391
|
+
|
|
392
|
+
// 重置所有状态
|
|
393
|
+
expectedTotal = null
|
|
394
|
+
downSize = 0
|
|
395
|
+
start = 0
|
|
396
|
+
useChunked = false
|
|
397
|
+
useRange = false
|
|
398
|
+
retries = 3
|
|
399
|
+
continue
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// 普通错误:重试
|
|
363
403
|
const err = error instanceof Error ? error : new Error(String(error))
|
|
364
404
|
if (--retries <= 0) {
|
|
365
405
|
writeStream.destroy()
|
|
366
|
-
await rm(tmpFile, { force: true })
|
|
406
|
+
await rm(tmpFile, { force: true }).catch(() => {})
|
|
367
407
|
throw new Error(`Download file with chunk failed! ${err.message}`, { cause: err })
|
|
368
408
|
}
|
|
369
409
|
// 失败后等待一小段时间再重试
|
|
370
|
-
await
|
|
371
|
-
} finally {
|
|
372
|
-
// 确保请求被清理(成功时也需要 abort 以释放资源)
|
|
373
|
-
requestAbortController.abort()
|
|
410
|
+
await setTimeout(100)
|
|
374
411
|
}
|
|
375
412
|
} while (expectedTotal === null || downSize < expectedTotal)
|
|
376
413
|
|
|
377
|
-
|
|
378
|
-
|
|
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 {
|
|
414
|
+
if (!writeStream.destroyed && !writeStream.writableFinished) {
|
|
415
|
+
writeStream.end()
|
|
390
416
|
await once(writeStream, 'finish', { signal })
|
|
391
|
-
} catch (e) {
|
|
392
|
-
const reason = signal.reason as unknown
|
|
393
|
-
if (reason instanceof Error) {
|
|
394
|
-
throw reason
|
|
395
|
-
}
|
|
396
|
-
throw e
|
|
397
|
-
} finally {
|
|
398
|
-
writeStream.off('error', onWriteError)
|
|
399
417
|
}
|
|
418
|
+
writeStream.off('error', onWriteError)
|
|
400
419
|
|
|
401
420
|
const readStream = createReadStream(tmpFile)
|
|
402
421
|
readStream.once('close', () => {
|
package/src/version.ts
CHANGED