dsh-dl-server 0.2.0 → 0.3.0
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 +22 -6
- package/README.zh.md +22 -6
- package/lib/mirrors.js +40 -0
- package/lib/queue.js +77 -7
- package/lib/tools.js +7 -1
- package/lib/transport.js +53 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -78,6 +78,15 @@ A download started in one session is visible to every other session — progress
|
|
|
78
78
|
| **Cumulative disk budget** | Enqueue counts what already-queued jobs still need, so a batch cannot quietly fill the disk. |
|
|
79
79
|
| **Failures are no longer silent** | Checkpoint write errors are counted and surfaced in `download_status` under `storage`; an unwritable destination is refused before the transfer starts. |
|
|
80
80
|
|
|
81
|
+
## New in 0.3.0
|
|
82
|
+
|
|
83
|
+
| | |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| **Queue de-duplication** | One destination is never downloaded twice. A finished job is reused while its file is still on disk; a different source claiming the same path fails with `dest-in-use`. |
|
|
86
|
+
| **Mirror self-verification** | Before a mirror is used, it is HEADed once: the length must equal the origin's, and when the origin advertises an ETag the mirror's must match too. |
|
|
87
|
+
|
|
88
|
+
> A mirror is a third party and serves whatever it likes. Length plus ETag catches a cached older build or an HTML error page; it does **not** catch a poisoned copy. Only `sha256` proves content, which is why mirrors are a fallback and not a guarantee.
|
|
89
|
+
|
|
81
90
|
## Example
|
|
82
91
|
|
|
83
92
|
```
|
|
@@ -123,12 +132,19 @@ The last row is the point: **on a per-connection-throttled host, segmentation be
|
|
|
123
132
|
|
|
124
133
|
## Roadmap
|
|
125
134
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
135
|
+
The roadmap names product stages (`v1`, `v1.1`, `v1.2`, `v2`); npm versions follow semver. They do not map one-to-one — use this table:
|
|
136
|
+
|
|
137
|
+
| npm | Roadmap | Content | Status |
|
|
138
|
+
| --- | --- | --- | --- |
|
|
139
|
+
| `0.1.x` | v1 | queue + persistence + seven tools + aria2 transport | released |
|
|
140
|
+
| `0.2.x` | v1.1 | mirrors / mirror cooldown / size-aware concurrency / session fairness / disk budget / loud failures | released |
|
|
141
|
+
| `0.3.x` | v1.2 | queue de-duplication / mirror self-verification | planned |
|
|
142
|
+
| `1.0.0` | v2 | shell interception / torrents / own segmented engine / content addressing | planned |
|
|
143
|
+
| `1.1.x` | v3 | user panel + callbacks into the agent | planned |
|
|
144
|
+
|
|
145
|
+
**Why 0.x lasts until v2:** `1.0.0` means "the tool names and config keys will not change incompatibly". Only v2 touches the transport and the tool set, so that is where 1.0 belongs.
|
|
146
|
+
|
|
147
|
+
**You never have to do the conversion yourself** — install the latest and you get the newest milestone.
|
|
132
148
|
|
|
133
149
|
## License
|
|
134
150
|
|
package/README.zh.md
CHANGED
|
@@ -78,6 +78,15 @@ DLS 做的是**队列层**,不是传输层。
|
|
|
78
78
|
| **磁盘余量累加** | 入队时把「已在队列里还没下完的」一起算进去,批量入队写不满盘 |
|
|
79
79
|
| **失败不再静默** | 断点写失败会计数并通过 `download_status` 的 `storage` 字段暴露;目标目录不可写在传输开始前就拒绝 |
|
|
80
80
|
|
|
81
|
+
## 0.3.0 新增
|
|
82
|
+
|
|
83
|
+
| 能力 | 说明 |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| **队列去重** | 同一个落地路径不会下两份。同源且文件已在盘上时直接复用已完成的任务;不同源抢同一路径会明确报 `dest-in-use` |
|
|
86
|
+
| **镜像自证同源** | 用镜像前先 HEAD 一次,长度必须与原始一致;原始给了 ETag 时镜像的 ETag 也必须相等,否则不用 |
|
|
87
|
+
|
|
88
|
+
> 镜像是第三方,它给什么我们不知道。长度 + ETag 能挡住「缓存了旧版本」「返回了错误页」这类问题,**挡不住投毒** —— 那只有 `sha256` 做得到。所以镜像只是兜底,真要保证内容就对 `sha256`。
|
|
89
|
+
|
|
81
90
|
## 用它
|
|
82
91
|
|
|
83
92
|
```
|
|
@@ -123,12 +132,19 @@ agent 会调 `download_enqueue` 两次拿到 `J#10` / `J#11`,然后 `download_
|
|
|
123
132
|
|
|
124
133
|
## 路线
|
|
125
134
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
135
|
+
路线图用 `v1 / v1.1 / v1.2 / v2` 讲产品阶段,npm 版本按语义化版本走。两者对不上一一对应,查这张表:
|
|
136
|
+
|
|
137
|
+
| npm 版本 | 路线图 | 内容 | 状态 |
|
|
138
|
+
| --- | --- | --- | --- |
|
|
139
|
+
| `0.1.x` | v1 | 队列 + 持久化 + 七个工具 + aria2 传输 | 已发布 |
|
|
140
|
+
| `0.2.x` | v1.1 | 镜像表 / 镜像冷却 / 并发分档 / 会话公平 / 磁盘余量 / 失败显式化 | 已发布 |
|
|
141
|
+
| `0.3.x` | v1.2 | 队列去重 / 镜像自证同源 | 计划 |
|
|
142
|
+
| `1.0.0` | v2 | 拦截 shell 下载 / 种子与磁力 / 自研分段引擎 / 内容寻址 | 计划 |
|
|
143
|
+
| `1.1.x` | v3 | 用户面板 + 操作回传 agent | 计划 |
|
|
144
|
+
|
|
145
|
+
**为什么 0.x 一直拖到 v2:** 1.0.0 在语义化版本里意味着「工具名和配置项从此不破坏性变更」。v2 才会动到传输层和工具集合,在那之前不配叫 1.0。
|
|
146
|
+
|
|
147
|
+
**升级时不用换算**:装最新版就行,`dsh plugin add dsh-dl-server` 永远指向当下最新。
|
|
132
148
|
|
|
133
149
|
## 许可
|
|
134
150
|
|
package/lib/mirrors.js
CHANGED
|
@@ -100,3 +100,43 @@ export function hostOf(url) {
|
|
|
100
100
|
return undefined
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
|
+
|
|
104
|
+
/** `W/"abc"` → `abc`; weak validators and quoting differ between servers. */
|
|
105
|
+
export function normalizeEtag(etag) {
|
|
106
|
+
if (typeof etag !== 'string') return ''
|
|
107
|
+
return etag.trim().replace(/^W\//i, '').replace(/^"|"$/g, '').toLowerCase()
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Can `candidate` be accepted as the same bytes the origin would serve?
|
|
112
|
+
*
|
|
113
|
+
* This is a heuristic, not a proof: an ETag is whatever the mirror declares.
|
|
114
|
+
* It catches the common failures — a mirror caching an older build, or answering
|
|
115
|
+
* with an HTML error page — and nothing more. Only `expectSha256` proves content.
|
|
116
|
+
*
|
|
117
|
+
* @param {{ length?: number, etag?: string }} origin
|
|
118
|
+
* @param {{ length?: number, etag?: string }} candidate
|
|
119
|
+
* @returns {{ ok: boolean, reason?: string }}
|
|
120
|
+
*/
|
|
121
|
+
export function sameResource(origin, candidate) {
|
|
122
|
+
const originLength = Number(origin?.length ?? -1)
|
|
123
|
+
const candidateLength = Number(candidate?.length ?? -1)
|
|
124
|
+
|
|
125
|
+
// No baseline (the origin probe failed): we cannot prove anything, and the
|
|
126
|
+
// mirror exists precisely for the case where the origin is unreachable.
|
|
127
|
+
if (!(originLength > 0)) return { ok: true, reason: 'origin-unknown' }
|
|
128
|
+
|
|
129
|
+
if (!(candidateLength > 0)) return { ok: false, reason: 'mirror did not report a length' }
|
|
130
|
+
if (candidateLength !== originLength) {
|
|
131
|
+
return { ok: false, reason: `length ${candidateLength} != ${originLength}` }
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const originEtag = normalizeEtag(origin?.etag)
|
|
135
|
+
if (originEtag) {
|
|
136
|
+
const candidateEtag = normalizeEtag(candidate?.etag)
|
|
137
|
+
if (!candidateEtag) return { ok: false, reason: 'mirror reports no ETag' }
|
|
138
|
+
if (candidateEtag !== originEtag) return { ok: false, reason: 'ETag differs' }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { ok: true }
|
|
142
|
+
}
|
package/lib/queue.js
CHANGED
|
@@ -38,6 +38,26 @@ function storageKeyOf(id) {
|
|
|
38
38
|
return seq === undefined ? id : `j${seq}`
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Comparison key for a destination path. Windows folds case and accepts both
|
|
43
|
+
* separators, so `D:\A\b.bin` and `d:/a/B.bin` are the same file there.
|
|
44
|
+
* @param {string} destPath
|
|
45
|
+
*/
|
|
46
|
+
function destKeyOf(destPath) {
|
|
47
|
+
const resolved = path.resolve(destPath).replace(/[\\/]+$/, '')
|
|
48
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** @param {string} file */
|
|
52
|
+
async function fileExists(file) {
|
|
53
|
+
try {
|
|
54
|
+
const info = await fs.stat(file)
|
|
55
|
+
return info.isFile() && info.size > 0
|
|
56
|
+
} catch {
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
41
61
|
export class QueueError extends Error {
|
|
42
62
|
/** @param {string} code @param {string} message */
|
|
43
63
|
constructor(code, message) {
|
|
@@ -99,6 +119,8 @@ export class Queue {
|
|
|
99
119
|
this.closed = false
|
|
100
120
|
this.timer = undefined
|
|
101
121
|
this.checkpointTimer = undefined
|
|
122
|
+
/** Session served last, so the next pass starts with a different one. */
|
|
123
|
+
this.lastServed = undefined
|
|
102
124
|
|
|
103
125
|
/** @type {{ errors: number, lastError?: string, lastAt?: number }} */
|
|
104
126
|
this.storage = { errors: 0 }
|
|
@@ -206,9 +228,37 @@ export class Queue {
|
|
|
206
228
|
}
|
|
207
229
|
|
|
208
230
|
const expanded = expandMirrors(spec.urls)
|
|
231
|
+
const destPath = path.resolve(spec.destPath)
|
|
232
|
+
const key = destKeyOf(destPath)
|
|
233
|
+
|
|
234
|
+
// Two jobs writing the same path is a conflict no matter which URL each one
|
|
235
|
+
// uses: aria2 would fight over the same .aria2 control file. Dedupe on the
|
|
236
|
+
// destination, not on the URL — one file can legitimately have many URLs.
|
|
237
|
+
const sameDest = [...this.jobs.entries()].map(([, record]) => record).filter((record) => destKeyOf(record.destPath) === key)
|
|
238
|
+
|
|
239
|
+
const active = sameDest.find((record) => !isTerminal(record.state))
|
|
240
|
+
if (active) {
|
|
241
|
+
if (active.urls[0] === expanded.urls[0]) {
|
|
242
|
+
this.log('info', `${active.id} reused by ${caller.session.slice(0, 8)} (same destination and source)`)
|
|
243
|
+
return { ...this.#view(active), deduplicated: true }
|
|
244
|
+
}
|
|
245
|
+
throw new QueueError(
|
|
246
|
+
'dest-in-use',
|
|
247
|
+
`${destPath} is already being downloaded as ${active.id} from ${active.urls[0]}; cancel it first or choose another destination`,
|
|
248
|
+
)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// A finished job for the same file is what the caller wants far more often
|
|
252
|
+
// than a second download. Only reuse it while the file is still on disk —
|
|
253
|
+
// a deleted file must be fetched again.
|
|
254
|
+
const finished = sameDest.find((record) => record.state === 'completed' && record.urls[0] === expanded.urls[0])
|
|
255
|
+
if (finished && (await fileExists(finished.destPath))) {
|
|
256
|
+
this.log('info', `${finished.id} reused by ${caller.session.slice(0, 8)} (already downloaded)`)
|
|
257
|
+
return { ...this.#view(finished), deduplicated: true }
|
|
258
|
+
}
|
|
209
259
|
|
|
210
|
-
await this.#assertDestWritable(
|
|
211
|
-
await this.#assertDiskSpace(
|
|
260
|
+
await this.#assertDestWritable(destPath)
|
|
261
|
+
await this.#assertDiskSpace(destPath, spec.expectedBytes)
|
|
212
262
|
|
|
213
263
|
const seq = await this.#nextSeq()
|
|
214
264
|
const id = formatJobId(seq)
|
|
@@ -223,7 +273,7 @@ export class Queue {
|
|
|
223
273
|
origin: caller.origin,
|
|
224
274
|
urls: expanded.urls,
|
|
225
275
|
mirrors: expanded.applied.length > 0 ? expanded.applied : undefined,
|
|
226
|
-
destPath
|
|
276
|
+
destPath,
|
|
227
277
|
state: 'queued',
|
|
228
278
|
createdAt: stamp,
|
|
229
279
|
updatedAt: stamp,
|
|
@@ -397,7 +447,7 @@ export class Queue {
|
|
|
397
447
|
owner: { workspace: record.ownerWorkspace, session: record.ownerSession },
|
|
398
448
|
origin: record.origin,
|
|
399
449
|
urls: record.urls,
|
|
400
|
-
mirrors: record
|
|
450
|
+
mirrors: this.#mirrorView(record),
|
|
401
451
|
dest: record.destPath,
|
|
402
452
|
bytesTotal: total,
|
|
403
453
|
bytesDone: done,
|
|
@@ -419,6 +469,21 @@ export class Queue {
|
|
|
419
469
|
})
|
|
420
470
|
}
|
|
421
471
|
|
|
472
|
+
/**
|
|
473
|
+
* Candidates were recorded at enqueue time; the transport only learns which of
|
|
474
|
+
* them actually proved to serve the same bytes once the transfer starts.
|
|
475
|
+
* @param {import('./types.js').JobRecord} record
|
|
476
|
+
*/
|
|
477
|
+
#mirrorView(record) {
|
|
478
|
+
if (!record.mirrors) return undefined
|
|
479
|
+
const report = this.transport?.mirrorReports?.get(record.urls[0])
|
|
480
|
+
return {
|
|
481
|
+
candidates: record.mirrors,
|
|
482
|
+
verified: report?.verified ?? [],
|
|
483
|
+
rejected: report?.rejected ?? [],
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
422
487
|
#schedule() {
|
|
423
488
|
if (this.closed || this.timer) return
|
|
424
489
|
this.timer = setTimeout(() => {
|
|
@@ -462,12 +527,16 @@ export class Queue {
|
|
|
462
527
|
else bySession.set(key, [record])
|
|
463
528
|
}
|
|
464
529
|
|
|
465
|
-
//
|
|
466
|
-
//
|
|
530
|
+
// Start each pass after the session that was served last, otherwise the
|
|
531
|
+
// first session in iteration order wins every tie and round-robin is a lie.
|
|
532
|
+
const order = [...bySession.keys()]
|
|
533
|
+
const startAt = this.lastServed && order.includes(this.lastServed) ? (order.indexOf(this.lastServed) + 1) % order.length : 0
|
|
534
|
+
const rotated = [...order.slice(startAt), ...order.slice(0, startAt)]
|
|
535
|
+
|
|
467
536
|
let progressed = true
|
|
468
537
|
while (progressed && this.active.size < this.config.maxConcurrentJobs) {
|
|
469
538
|
progressed = false
|
|
470
|
-
for (const key of
|
|
539
|
+
for (const key of rotated) {
|
|
471
540
|
if (this.active.size >= this.config.maxConcurrentJobs) break
|
|
472
541
|
const list = bySession.get(key)
|
|
473
542
|
if (!list || list.length === 0) continue
|
|
@@ -475,6 +544,7 @@ export class Queue {
|
|
|
475
544
|
const record = list.shift()
|
|
476
545
|
if (this.active.has(record.id)) continue
|
|
477
546
|
running.set(key, (running.get(key) ?? 0) + 1)
|
|
547
|
+
this.lastServed = key
|
|
478
548
|
void this.#run(record)
|
|
479
549
|
progressed = true
|
|
480
550
|
}
|
package/lib/tools.js
CHANGED
|
@@ -90,7 +90,13 @@ export function registerTools({ ctx, queue, config }) {
|
|
|
90
90
|
{ urls: args.urls, destPath: args.dest, expectSha256: args.sha256 },
|
|
91
91
|
withCaller(exec),
|
|
92
92
|
)
|
|
93
|
-
return {
|
|
93
|
+
return {
|
|
94
|
+
ok: true,
|
|
95
|
+
jobId: job.id,
|
|
96
|
+
state: job.state,
|
|
97
|
+
dest: job.dest,
|
|
98
|
+
...(job.deduplicated ? { deduplicated: true } : {}),
|
|
99
|
+
}
|
|
94
100
|
} catch (error) {
|
|
95
101
|
return fail(error)
|
|
96
102
|
}
|
package/lib/transport.js
CHANGED
|
@@ -6,6 +6,7 @@ import path from 'node:path'
|
|
|
6
6
|
import { Readable } from 'node:stream'
|
|
7
7
|
import { pipeline } from 'node:stream/promises'
|
|
8
8
|
import { randomBytes } from 'node:crypto'
|
|
9
|
+
import { sameResource } from './mirrors.js'
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Transport layer. Two channels: an aria2c JSON-RPC daemon when one is
|
|
@@ -194,6 +195,8 @@ export class Transport {
|
|
|
194
195
|
this.mode = 'direct'
|
|
195
196
|
this.aria2 = undefined
|
|
196
197
|
this.rpc = undefined
|
|
198
|
+
/** @type {Map<string, { verified: string[], rejected: { url: string, reason: string }[] }>} */
|
|
199
|
+
this.mirrorReports = new Map()
|
|
197
200
|
}
|
|
198
201
|
|
|
199
202
|
/** Detect aria2 and start the daemon if possible. Never throws. */
|
|
@@ -247,15 +250,16 @@ export class Transport {
|
|
|
247
250
|
|
|
248
251
|
/** @param {TransferOptions} options @returns {Promise<TransferResult>} */
|
|
249
252
|
async transfer(options) {
|
|
250
|
-
const
|
|
251
|
-
const
|
|
252
|
-
const plan = planTransfer(declared, options.connectionsCap ?? options.connections ?? this.config.connectionsPerJob ?? 16)
|
|
253
|
+
const origin = await this.#probeHead(options.urls[0], options.signal)
|
|
254
|
+
const plan = planTransfer(origin.length, options.connectionsCap ?? options.connections ?? this.config.connectionsPerJob ?? 16)
|
|
253
255
|
const connections = options.connections ?? plan.connections
|
|
256
|
+
|
|
257
|
+
const urls = await this.#verifiedUrls(options.urls, origin, options.signal)
|
|
254
258
|
const useAria2 = this.mode === 'aria2' && this.rpc && !options.preferDirect && !plan.direct
|
|
255
259
|
|
|
256
260
|
if (useAria2) {
|
|
257
261
|
try {
|
|
258
|
-
const result = await this.#transferAria2({ ...options, connections })
|
|
262
|
+
const result = await this.#transferAria2({ ...options, urls, connections })
|
|
259
263
|
options.onUrlResult?.(urls[0], true)
|
|
260
264
|
return result
|
|
261
265
|
} catch (error) {
|
|
@@ -263,19 +267,53 @@ export class Transport {
|
|
|
263
267
|
this.log('warn', `aria2 transfer failed, retrying single-stream: ${error.message}`)
|
|
264
268
|
}
|
|
265
269
|
}
|
|
266
|
-
return this.#transferDirect(options)
|
|
270
|
+
return this.#transferDirect({ ...options, urls })
|
|
267
271
|
}
|
|
268
272
|
|
|
269
273
|
/**
|
|
270
|
-
*
|
|
271
|
-
*
|
|
274
|
+
* Keep only the mirrors that can be shown to serve the same bytes as the
|
|
275
|
+
* origin. A mirror is a third party: it may hold an older build or answer with
|
|
276
|
+
* an HTML error page, and `sha256` is the only proof of content. Length plus
|
|
277
|
+
* ETag catches the common mistakes; the reasoning is documented in SPEC §11.2.
|
|
272
278
|
* @param {string[]} urls
|
|
279
|
+
* @param {{ length: number, etag?: string }} origin
|
|
280
|
+
* @param {AbortSignal} [signal]
|
|
281
|
+
*/
|
|
282
|
+
async #verifiedUrls(urls, origin, signal) {
|
|
283
|
+
if (urls.length <= 1) return urls
|
|
284
|
+
|
|
285
|
+
const accepted = [urls[0]]
|
|
286
|
+
/** @type {{ url: string, reason: string }[]} */
|
|
287
|
+
const rejected = []
|
|
288
|
+
|
|
289
|
+
for (const mirror of urls.slice(1)) {
|
|
290
|
+
const head = await this.#probeHead(mirror, signal)
|
|
291
|
+
const verdict = sameResource(origin, head)
|
|
292
|
+
if (verdict.ok) {
|
|
293
|
+
accepted.push(mirror)
|
|
294
|
+
if (verdict.reason === 'origin-unknown') {
|
|
295
|
+
this.log('warn', `mirror ${mirror} kept unverified: the origin did not report a length`)
|
|
296
|
+
}
|
|
297
|
+
} else {
|
|
298
|
+
rejected.push({ url: mirror, reason: verdict.reason ?? 'unverified' })
|
|
299
|
+
this.log('warn', `mirror ${mirror} rejected: ${verdict.reason}`)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
this.mirrorReports.set(urls[0], { verified: accepted.slice(1), rejected })
|
|
304
|
+
return accepted
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Best-effort HEAD: the length and validator used for sizing and for proving a
|
|
309
|
+
* mirror matches. A failure is not an error — unknown size only means the
|
|
310
|
+
* planner stays conservative.
|
|
311
|
+
* @param {string} url
|
|
273
312
|
* @param {AbortSignal} [signal]
|
|
274
|
-
* @returns {Promise<
|
|
313
|
+
* @returns {Promise<{ length: number, etag?: string }>}
|
|
275
314
|
*/
|
|
276
|
-
async #
|
|
277
|
-
|
|
278
|
-
if (!url) return -1
|
|
315
|
+
async #probeHead(url, signal) {
|
|
316
|
+
if (!url) return { length: -1 }
|
|
279
317
|
const controller = new AbortController()
|
|
280
318
|
const timer = setTimeout(() => controller.abort(), 5000)
|
|
281
319
|
timer.unref?.()
|
|
@@ -284,11 +322,12 @@ export class Transport {
|
|
|
284
322
|
|
|
285
323
|
try {
|
|
286
324
|
const response = await fetch(url, { method: 'HEAD', signal: controller.signal })
|
|
287
|
-
if (!response.ok) return -1
|
|
325
|
+
if (!response.ok) return { length: -1 }
|
|
288
326
|
const length = Number(response.headers.get('content-length') ?? 0)
|
|
289
|
-
|
|
327
|
+
const etag = response.headers.get('etag') ?? undefined
|
|
328
|
+
return { length: Number.isFinite(length) && length > 0 ? length : -1, etag }
|
|
290
329
|
} catch {
|
|
291
|
-
return -1
|
|
330
|
+
return { length: -1 }
|
|
292
331
|
} finally {
|
|
293
332
|
clearTimeout(timer)
|
|
294
333
|
signal?.removeEventListener('abort', onAbort)
|