dsh-dl-server 0.1.1 → 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 +39 -8
- package/README.zh.md +40 -9
- package/lib/domain.js +4 -0
- package/lib/mirrors.js +142 -0
- package/lib/queue.js +870 -591
- package/lib/tools.js +10 -3
- package/lib/transport.js +108 -5
- package/package.json +1 -1
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
|
}
|
|
@@ -111,14 +117,15 @@ export function registerTools({ ctx, queue, config }) {
|
|
|
111
117
|
additionalProperties: false,
|
|
112
118
|
},
|
|
113
119
|
async execute(args) {
|
|
120
|
+
const health = queue.health()
|
|
114
121
|
if (args?.id) {
|
|
115
122
|
const job = queue.get(args.id)
|
|
116
123
|
return job
|
|
117
|
-
? { ok: true, count: 1, jobs: [job] }
|
|
124
|
+
? { ok: true, count: 1, jobs: [job], ...health }
|
|
118
125
|
: { ok: false, code: 'not-found', message: `${args.id} does not exist` }
|
|
119
126
|
}
|
|
120
127
|
const jobs = queue.list()
|
|
121
|
-
return { ok: true, count: jobs.length, jobs }
|
|
128
|
+
return { ok: true, count: jobs.length, jobs, ...health }
|
|
122
129
|
},
|
|
123
130
|
})
|
|
124
131
|
|
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
|
|
@@ -16,10 +17,13 @@ import { randomBytes } from 'node:crypto'
|
|
|
16
17
|
* @typedef {object} TransferOptions
|
|
17
18
|
* @property {string[]} urls candidate sources, tried in order
|
|
18
19
|
* @property {string} destPath absolute final file path
|
|
19
|
-
* @property {number} [connections] aria2 split, default 16
|
|
20
|
+
* @property {number} [connections] aria2 split for a large file, default 16
|
|
21
|
+
* @property {number} [connectionsCap] upper bound when the size picks the count
|
|
22
|
+
* @property {boolean} [preferDirect] skip aria2 for this job
|
|
20
23
|
* @property {number} [speedLimitKbps] 0 or absent means unlimited
|
|
21
24
|
* @property {string} [proxy] e.g. http://127.0.0.1:65532
|
|
22
25
|
* @property {number} [bytesDone] resume hint for the direct channel
|
|
26
|
+
* @property {(url: string, ok: boolean) => void} [onUrlResult]
|
|
23
27
|
* @property {(done: number, total: number) => void} [onProgress]
|
|
24
28
|
* @property {AbortSignal} [signal]
|
|
25
29
|
*
|
|
@@ -28,6 +32,28 @@ import { randomBytes } from 'node:crypto'
|
|
|
28
32
|
* @property {number} total -1 when the server never reported one
|
|
29
33
|
*/
|
|
30
34
|
|
|
35
|
+
/** Below this, aria2's setup cost exceeds any segmentation gain. */
|
|
36
|
+
export const SMALL_FILE_BYTES = 1 * 1024 * 1024
|
|
37
|
+
/** From this size up, extra connections pay for themselves. */
|
|
38
|
+
export const LARGE_FILE_BYTES = 8 * 1024 * 1024
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Decide the channel and the number of connections from the file size.
|
|
42
|
+
*
|
|
43
|
+
* Segmentation only helps once `min-split-size=1M` can produce several pieces,
|
|
44
|
+
* and too many connections on a per-IP-throttled host hurts rather than helps.
|
|
45
|
+
* @param {number} sizeBytes
|
|
46
|
+
* @param {number} [cap]
|
|
47
|
+
* @returns {{ connections: number, direct: boolean }}
|
|
48
|
+
*/
|
|
49
|
+
export function planTransfer(sizeBytes, cap = 16) {
|
|
50
|
+
if (!Number.isFinite(sizeBytes) || sizeBytes <= 0) return { connections: Math.min(cap, 8), direct: false }
|
|
51
|
+
if (sizeBytes < SMALL_FILE_BYTES) return { connections: 1, direct: true }
|
|
52
|
+
if (sizeBytes < LARGE_FILE_BYTES) return { connections: Math.min(4, cap), direct: false }
|
|
53
|
+
return { connections: cap, direct: false }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
31
57
|
const IS_WIN = process.platform === 'win32'
|
|
32
58
|
const ARIA2_BIN = IS_WIN ? 'aria2c.exe' : 'aria2c'
|
|
33
59
|
|
|
@@ -169,6 +195,8 @@ export class Transport {
|
|
|
169
195
|
this.mode = 'direct'
|
|
170
196
|
this.aria2 = undefined
|
|
171
197
|
this.rpc = undefined
|
|
198
|
+
/** @type {Map<string, { verified: string[], rejected: { url: string, reason: string }[] }>} */
|
|
199
|
+
this.mirrorReports = new Map()
|
|
172
200
|
}
|
|
173
201
|
|
|
174
202
|
/** Detect aria2 and start the daemon if possible. Never throws. */
|
|
@@ -222,15 +250,88 @@ export class Transport {
|
|
|
222
250
|
|
|
223
251
|
/** @param {TransferOptions} options @returns {Promise<TransferResult>} */
|
|
224
252
|
async transfer(options) {
|
|
225
|
-
|
|
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)
|
|
255
|
+
const connections = options.connections ?? plan.connections
|
|
256
|
+
|
|
257
|
+
const urls = await this.#verifiedUrls(options.urls, origin, options.signal)
|
|
258
|
+
const useAria2 = this.mode === 'aria2' && this.rpc && !options.preferDirect && !plan.direct
|
|
259
|
+
|
|
260
|
+
if (useAria2) {
|
|
226
261
|
try {
|
|
227
|
-
|
|
262
|
+
const result = await this.#transferAria2({ ...options, urls, connections })
|
|
263
|
+
options.onUrlResult?.(urls[0], true)
|
|
264
|
+
return result
|
|
228
265
|
} catch (error) {
|
|
229
266
|
if (options.signal?.aborted) throw error
|
|
230
267
|
this.log('warn', `aria2 transfer failed, retrying single-stream: ${error.message}`)
|
|
231
268
|
}
|
|
232
269
|
}
|
|
233
|
-
return this.#transferDirect(options)
|
|
270
|
+
return this.#transferDirect({ ...options, urls })
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
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.
|
|
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
|
|
312
|
+
* @param {AbortSignal} [signal]
|
|
313
|
+
* @returns {Promise<{ length: number, etag?: string }>}
|
|
314
|
+
*/
|
|
315
|
+
async #probeHead(url, signal) {
|
|
316
|
+
if (!url) return { length: -1 }
|
|
317
|
+
const controller = new AbortController()
|
|
318
|
+
const timer = setTimeout(() => controller.abort(), 5000)
|
|
319
|
+
timer.unref?.()
|
|
320
|
+
const onAbort = () => controller.abort()
|
|
321
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
322
|
+
|
|
323
|
+
try {
|
|
324
|
+
const response = await fetch(url, { method: 'HEAD', signal: controller.signal })
|
|
325
|
+
if (!response.ok) return { length: -1 }
|
|
326
|
+
const length = Number(response.headers.get('content-length') ?? 0)
|
|
327
|
+
const etag = response.headers.get('etag') ?? undefined
|
|
328
|
+
return { length: Number.isFinite(length) && length > 0 ? length : -1, etag }
|
|
329
|
+
} catch {
|
|
330
|
+
return { length: -1 }
|
|
331
|
+
} finally {
|
|
332
|
+
clearTimeout(timer)
|
|
333
|
+
signal?.removeEventListener('abort', onAbort)
|
|
334
|
+
}
|
|
234
335
|
}
|
|
235
336
|
|
|
236
337
|
/** @param {TransferOptions} options */
|
|
@@ -284,7 +385,7 @@ export class Transport {
|
|
|
284
385
|
}
|
|
285
386
|
|
|
286
387
|
/** @param {TransferOptions} options */
|
|
287
|
-
async #transferDirect({ urls, destPath, bytesDone = 0, onProgress, signal }) {
|
|
388
|
+
async #transferDirect({ urls, destPath, bytesDone = 0, onProgress, onUrlResult, signal }) {
|
|
288
389
|
await fs.mkdir(path.dirname(destPath), { recursive: true })
|
|
289
390
|
const partPath = `${destPath}.part`
|
|
290
391
|
let lastError
|
|
@@ -326,10 +427,12 @@ export class Transport {
|
|
|
326
427
|
await pipeline(source, sink)
|
|
327
428
|
onProgress?.(written, total)
|
|
328
429
|
await fs.rename(partPath, destPath)
|
|
430
|
+
onUrlResult?.(url, true)
|
|
329
431
|
return { bytes: written, total }
|
|
330
432
|
} catch (error) {
|
|
331
433
|
if (error?.name === 'AbortError' || signal?.aborted) throw error
|
|
332
434
|
lastError = error
|
|
435
|
+
onUrlResult?.(url, false)
|
|
333
436
|
}
|
|
334
437
|
}
|
|
335
438
|
throw lastError ?? new Error('no source available')
|