dsh-dl-server 0.3.1 → 0.4.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/lib/tools.js CHANGED
@@ -80,6 +80,16 @@ export function registerTools({ ctx, queue, config }) {
80
80
  type: 'string',
81
81
  description: 'Optional expected SHA-256. The job fails if the finished file does not match.',
82
82
  },
83
+ headers: {
84
+ type: 'object',
85
+ description:
86
+ 'Optional HTTP request headers sent with every attempt, including mirror probes and ' +
87
+ 'resumes. Use this for CDNs that refuse a bare request — Bilibili media needs ' +
88
+ '{"Referer": "https://www.bilibili.com"}, some hosts want a Cookie or User-Agent. ' +
89
+ 'Range, Host, Content-Length and similar transport headers cannot be set. ' +
90
+ 'Values are stored locally but never echoed back: job output lists header names only.',
91
+ additionalProperties: { type: 'string' },
92
+ },
83
93
  },
84
94
  required: ['urls', 'dest'],
85
95
  additionalProperties: false,
@@ -87,7 +97,12 @@ export function registerTools({ ctx, queue, config }) {
87
97
  async execute(args, exec) {
88
98
  try {
89
99
  const job = await queue.enqueue(
90
- { urls: args.urls, destPath: args.dest, expectSha256: args.sha256 },
100
+ {
101
+ urls: args.urls,
102
+ destPath: args.dest,
103
+ expectSha256: args.sha256,
104
+ headers: args.headers,
105
+ },
91
106
  withCaller(exec),
92
107
  )
93
108
  return {
package/lib/transport.js CHANGED
@@ -7,6 +7,7 @@ import { Readable } from 'node:stream'
7
7
  import { pipeline } from 'node:stream/promises'
8
8
  import { randomBytes } from 'node:crypto'
9
9
  import { sameResource } from './mirrors.js'
10
+ import { toAria2Headers } from './headers.js'
10
11
 
11
12
  /**
12
13
  * Transport layer. Two channels: an aria2c JSON-RPC daemon when one is
@@ -23,6 +24,8 @@ import { sameResource } from './mirrors.js'
23
24
  * @property {number} [speedLimitKbps] 0 or absent means unlimited
24
25
  * @property {string} [proxy] e.g. http://127.0.0.1:65532
25
26
  * @property {number} [bytesDone] resume hint for the direct channel
27
+ * @property {Record<string, string>} [headers] custom HTTP headers sent with
28
+ * every attempt (probe, aria2, direct)
26
29
  * @property {(url: string, ok: boolean) => void} [onUrlResult]
27
30
  * @property {(done: number, total: number) => void} [onProgress]
28
31
  * @property {AbortSignal} [signal]
@@ -57,6 +60,30 @@ export function planTransfer(sizeBytes, cap = 16) {
57
60
  const IS_WIN = process.platform === 'win32'
58
61
  const ARIA2_BIN = IS_WIN ? 'aria2c.exe' : 'aria2c'
59
62
 
63
+ /**
64
+ * The command that installs aria2 on this platform.
65
+ *
66
+ * Missing aria2 is the single most common fresh-install problem: the queue keeps
67
+ * working through the single-stream fallback, so nothing fails loudly and the
68
+ * user just concludes that DLS is slow. Every surface that mentions the fallback
69
+ * quotes this string, so the advice cannot drift from the detection code.
70
+ *
71
+ * @param {NodeJS.Platform} [platform]
72
+ * @returns {string}
73
+ */
74
+ export function aria2InstallHint(platform = process.platform) {
75
+ switch (platform) {
76
+ case 'win32':
77
+ return 'winget install aria2.aria2 (或从 https://github.com/aria2/aria2/releases 下载)'
78
+ case 'darwin':
79
+ return 'brew install aria2'
80
+ case 'linux':
81
+ return 'sudo apt install aria2 (Fedora: sudo dnf install aria2,Arch: sudo pacman -S aria2)'
82
+ default:
83
+ return '安装 aria2 并把 aria2c 放进 PATH'
84
+ }
85
+ }
86
+
60
87
  /** Absolute locations worth checking beyond PATH. */
61
88
  function extraCandidates() {
62
89
  if (IS_WIN) {
@@ -193,17 +220,36 @@ export class Transport {
193
220
  this.config = config
194
221
  this.log = log
195
222
  this.mode = 'direct'
223
+ this.aria2Missing = false
196
224
  this.aria2 = undefined
197
225
  this.rpc = undefined
198
226
  /** @type {Map<string, { verified: string[], rejected: { url: string, reason: string }[] }>} */
199
227
  this.mirrorReports = new Map()
200
228
  }
201
229
 
230
+ /**
231
+ * Channel snapshot for `download_status`. When aria2 is missing the view
232
+ * carries the install hint so an agent reading a slow transfer can tell the
233
+ * user what to fix instead of guessing.
234
+ */
235
+ modeInfo() {
236
+ if (this.aria2Missing) {
237
+ return { mode: 'direct', aria2Missing: true, hint: aria2InstallHint() }
238
+ }
239
+ return { mode: this.mode }
240
+ }
241
+
202
242
  /** Detect aria2 and start the daemon if possible. Never throws. */
203
243
  async init() {
204
244
  const binary = await findAria2(this.config.aria2Path)
205
245
  if (!binary) {
206
- this.log('info', 'aria2c not found, using the single-stream fallback')
246
+ this.aria2Missing = true
247
+ // warn, not info: this is the difference between 136 MB/s and 13 MB/s and
248
+ // the fallback is silent otherwise.
249
+ this.log(
250
+ 'warn',
251
+ `aria2c 未找到,回退到单流模式(速度约为并发的 1/10)。安装:${aria2InstallHint()}`,
252
+ )
207
253
  return
208
254
  }
209
255
 
@@ -250,11 +296,12 @@ export class Transport {
250
296
 
251
297
  /** @param {TransferOptions} options @returns {Promise<TransferResult>} */
252
298
  async transfer(options) {
253
- const origin = await this.#probeHead(options.urls[0], options.signal)
299
+ const headers = options.headers
300
+ const origin = await this.#probeHead(options.urls[0], options.signal, headers)
254
301
  const plan = planTransfer(origin.length, options.connectionsCap ?? options.connections ?? this.config.connectionsPerJob ?? 16)
255
302
  const connections = options.connections ?? plan.connections
256
303
 
257
- const urls = await this.#verifiedUrls(options.urls, origin, options.signal)
304
+ const urls = await this.#verifiedUrls(options.urls, origin, options.signal, headers)
258
305
  const useAria2 = this.mode === 'aria2' && this.rpc && !options.preferDirect && !plan.direct
259
306
 
260
307
  if (useAria2) {
@@ -278,8 +325,9 @@ export class Transport {
278
325
  * @param {string[]} urls
279
326
  * @param {{ length: number, etag?: string }} origin
280
327
  * @param {AbortSignal} [signal]
328
+ * @param {Record<string, string>} [headers]
281
329
  */
282
- async #verifiedUrls(urls, origin, signal) {
330
+ async #verifiedUrls(urls, origin, signal, headers) {
283
331
  if (urls.length <= 1) return urls
284
332
 
285
333
  const accepted = [urls[0]]
@@ -287,7 +335,7 @@ export class Transport {
287
335
  const rejected = []
288
336
 
289
337
  for (const mirror of urls.slice(1)) {
290
- const head = await this.#probeHead(mirror, signal)
338
+ const head = await this.#probeHead(mirror, signal, headers)
291
339
  const verdict = sameResource(origin, head)
292
340
  if (verdict.ok) {
293
341
  accepted.push(mirror)
@@ -310,9 +358,10 @@ export class Transport {
310
358
  * planner stays conservative.
311
359
  * @param {string} url
312
360
  * @param {AbortSignal} [signal]
361
+ * @param {Record<string, string>} [headers] needed for CDNs that 403 a bare HEAD
313
362
  * @returns {Promise<{ length: number, etag?: string }>}
314
363
  */
315
- async #probeHead(url, signal) {
364
+ async #probeHead(url, signal, headers) {
316
365
  if (!url) return { length: -1 }
317
366
  const controller = new AbortController()
318
367
  const timer = setTimeout(() => controller.abort(), 5000)
@@ -321,7 +370,7 @@ export class Transport {
321
370
  signal?.addEventListener('abort', onAbort, { once: true })
322
371
 
323
372
  try {
324
- const response = await fetch(url, { method: 'HEAD', signal: controller.signal })
373
+ const response = await fetch(url, { method: 'HEAD', headers, signal: controller.signal })
325
374
  if (!response.ok) return { length: -1 }
326
375
  const length = Number(response.headers.get('content-length') ?? 0)
327
376
  const etag = response.headers.get('etag') ?? undefined
@@ -335,7 +384,7 @@ export class Transport {
335
384
  }
336
385
 
337
386
  /** @param {TransferOptions} options */
338
- async #transferAria2({ urls, destPath, connections, speedLimitKbps, onProgress, signal }) {
387
+ async #transferAria2({ urls, destPath, connections, speedLimitKbps, headers, onProgress, signal }) {
339
388
  const rpc = this.rpc
340
389
  await fs.mkdir(path.dirname(destPath), { recursive: true })
341
390
 
@@ -351,6 +400,10 @@ export class Transport {
351
400
  }
352
401
  const limit = speedLimitKbps ?? this.config.speedLimitKbps ?? 0
353
402
  if (limit > 0) options['max-download-limit'] = `${limit}K`
403
+ // aria2 wants one "Name: Value" string per `--header`; it applies them to
404
+ // every URI of the download, which is what a mirror group needs.
405
+ const headerList = toAria2Headers(headers)
406
+ if (headerList) options.header = headerList
354
407
 
355
408
  const gid = await rpc.call('aria2.addUri', [urls, options])
356
409
 
@@ -385,7 +438,7 @@ export class Transport {
385
438
  }
386
439
 
387
440
  /** @param {TransferOptions} options */
388
- async #transferDirect({ urls, destPath, bytesDone = 0, onProgress, onUrlResult, signal }) {
441
+ async #transferDirect({ urls, destPath, bytesDone = 0, headers, onProgress, onUrlResult, signal }) {
389
442
  await fs.mkdir(path.dirname(destPath), { recursive: true })
390
443
  const partPath = `${destPath}.part`
391
444
  let lastError
@@ -399,10 +452,12 @@ export class Transport {
399
452
  offset = bytesDone
400
453
  }
401
454
 
402
- const headers = {}
403
- if (offset > 0) headers.range = `bytes=${offset}-`
455
+ // Custom headers first, then `range` — resume owns that one, and
456
+ // `normalizeHeaders` already rejects a caller-supplied Range.
457
+ const requestHeaders = { ...headers }
458
+ if (offset > 0) requestHeaders.range = `bytes=${offset}-`
404
459
 
405
- const response = await fetch(url, { headers, signal })
460
+ const response = await fetch(url, { headers: requestHeaders, signal })
406
461
  if (!response.ok) throw new Error(`HTTP ${response.status}`)
407
462
 
408
463
  const ranged = response.status === 206
package/lib/types.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * @property {string} [origin] subagent name, display and audit only
13
13
  * @property {string[]} urls
14
14
  * @property {string} destPath
15
+ * @property {Record<string, string>} [headers] custom HTTP headers, sent on every attempt
15
16
  * @property {JobState} state
16
17
  * @property {PauseReason} [pauseReason]
17
18
  * @property {number} createdAt
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-dl-server",
3
- "version": "0.3.1",
3
+ "version": "0.4.1",
4
4
  "description": "DLS — DSH Download Server: a global, session-aware, persistent download queue for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -42,7 +42,7 @@
42
42
  },
43
43
  "scripts": {
44
44
  "test": "node --test",
45
- "check": "node --check lib/index.js && node --check lib/domain.js && node --check lib/types.js"
45
+ "check": "node --check lib/index.js && node --check lib/domain.js && node --check lib/types.js && node --check lib/headers.js"
46
46
  },
47
47
  "dependencies": {
48
48
  "zod": "^4.4.3"