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/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 { ok: true, jobId: job.id, state: job.state, dest: job.dest }
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
- if (this.mode === 'aria2' && this.rpc) {
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
- return await this.#transferAria2(options)
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')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-dl-server",
3
- "version": "0.1.1",
3
+ "version": "0.3.0",
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",