dsh-dl-server 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +46 -2
- package/README.zh.md +45 -0
- package/cordis.patch.yml +16 -0
- package/lib/bootlog.js +31 -0
- package/lib/domain.js +51 -0
- package/lib/index.js +92 -0
- package/lib/queue.js +591 -0
- package/lib/tools.js +216 -0
- package/lib/transport.js +357 -0
- package/lib/types.js +54 -0
- package/package.json +55 -4
package/lib/tools.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { CLEANUP_SCOPES, DEFAULT_SCOPE, QueueError } from './queue.js'
|
|
2
|
+
import { mark, markFailure } from './bootlog.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Turn a domain-level failure into a canonical value instead of an exception.
|
|
6
|
+
* Only unexpected failures propagate as errors.
|
|
7
|
+
* @param {unknown} error
|
|
8
|
+
*/
|
|
9
|
+
function fail(error) {
|
|
10
|
+
if (error instanceof QueueError) {
|
|
11
|
+
return { ok: false, code: error.code, message: error.message }
|
|
12
|
+
}
|
|
13
|
+
throw error
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Every DLS tool returns one canonical JSON object.
|
|
18
|
+
* `ToolDefinition.output` is mandatory and its `render` is mandatory too, so the
|
|
19
|
+
* shape lives here instead of being repeated seven times.
|
|
20
|
+
*/
|
|
21
|
+
const CANONICAL_OUTPUT = {
|
|
22
|
+
schema: { type: 'object', additionalProperties: true },
|
|
23
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Best-effort caller identity. DSH's tool execution context is the only source;
|
|
28
|
+
* when a field is missing we fall back to a stable placeholder so the queue
|
|
29
|
+
* still behaves sanely in a single-session profile.
|
|
30
|
+
* @param {any} exec
|
|
31
|
+
*/
|
|
32
|
+
export function resolveCaller(exec) {
|
|
33
|
+
const agent = exec?.agent
|
|
34
|
+
const session =
|
|
35
|
+
agent?.sessionId ?? agent?.session?.id ?? exec?.session?.id ?? exec?.sessionId ?? 'local'
|
|
36
|
+
const workspace =
|
|
37
|
+
agent?.workspaceId ?? agent?.workspace?.id ?? exec?.workspaceId ?? 'local'
|
|
38
|
+
return { workspace: String(workspace), session: String(session) }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Register the seven DLS tools.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} deps
|
|
45
|
+
* @param {any} deps.ctx
|
|
46
|
+
* @param {import('./queue.js').Queue} deps.queue
|
|
47
|
+
* @param {object} deps.config
|
|
48
|
+
*/
|
|
49
|
+
export function registerTools({ ctx, queue, config }) {
|
|
50
|
+
mark('registerTools enter')
|
|
51
|
+
|
|
52
|
+
ctx.inject(['tools'], (scope) => {
|
|
53
|
+
try {
|
|
54
|
+
mark('tools inject fired', `hasTools=${typeof scope?.tools?.register === 'function'}`)
|
|
55
|
+
const tools = scope.tools
|
|
56
|
+
const withCaller = (exec) => resolveCaller(exec)
|
|
57
|
+
|
|
58
|
+
tools.register({
|
|
59
|
+
name: 'download_enqueue',
|
|
60
|
+
output: CANONICAL_OUTPUT,
|
|
61
|
+
description:
|
|
62
|
+
'Queue a file for download without blocking. Returns a job id such as J#1042 immediately; ' +
|
|
63
|
+
'use download_wait or download_status to follow it. The job survives the turn, the session and ' +
|
|
64
|
+
'a DSH restart. Every session can see every job, but only the owning session can pause, resume ' +
|
|
65
|
+
'or cancel an in-flight one.',
|
|
66
|
+
parameters: {
|
|
67
|
+
type: 'object',
|
|
68
|
+
properties: {
|
|
69
|
+
urls: {
|
|
70
|
+
type: 'array',
|
|
71
|
+
items: { type: 'string' },
|
|
72
|
+
minItems: 1,
|
|
73
|
+
description: 'Candidate HTTP(S) URLs, tried in order. The first that answers wins.',
|
|
74
|
+
},
|
|
75
|
+
dest: {
|
|
76
|
+
type: 'string',
|
|
77
|
+
description: 'Absolute destination path including the file name.',
|
|
78
|
+
},
|
|
79
|
+
sha256: {
|
|
80
|
+
type: 'string',
|
|
81
|
+
description: 'Optional expected SHA-256. The job fails if the finished file does not match.',
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
required: ['urls', 'dest'],
|
|
85
|
+
additionalProperties: false,
|
|
86
|
+
},
|
|
87
|
+
async execute(args, exec) {
|
|
88
|
+
try {
|
|
89
|
+
const job = await queue.enqueue(
|
|
90
|
+
{ urls: args.urls, destPath: args.dest, expectSha256: args.sha256 },
|
|
91
|
+
withCaller(exec),
|
|
92
|
+
)
|
|
93
|
+
return { ok: true, jobId: job.id, state: job.state, dest: job.dest }
|
|
94
|
+
} catch (error) {
|
|
95
|
+
return fail(error)
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
tools.register({
|
|
101
|
+
name: 'download_status',
|
|
102
|
+
output: CANONICAL_OUTPUT,
|
|
103
|
+
description:
|
|
104
|
+
'List DLS jobs. Every session sees every job; the owner field shows which session owns each one. ' +
|
|
105
|
+
'Omit id to list all jobs, pass an id such as J#1042 for a single job.',
|
|
106
|
+
parameters: {
|
|
107
|
+
type: 'object',
|
|
108
|
+
properties: {
|
|
109
|
+
id: { type: 'string', description: 'Optional job id. Omit to list every job.' },
|
|
110
|
+
},
|
|
111
|
+
additionalProperties: false,
|
|
112
|
+
},
|
|
113
|
+
async execute(args) {
|
|
114
|
+
if (args?.id) {
|
|
115
|
+
const job = queue.get(args.id)
|
|
116
|
+
return job
|
|
117
|
+
? { ok: true, count: 1, jobs: [job] }
|
|
118
|
+
: { ok: false, code: 'not-found', message: `${args.id} does not exist` }
|
|
119
|
+
}
|
|
120
|
+
const jobs = queue.list()
|
|
121
|
+
return { ok: true, count: jobs.length, jobs }
|
|
122
|
+
},
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
tools.register({
|
|
126
|
+
name: 'download_wait',
|
|
127
|
+
output: CANONICAL_OUTPUT,
|
|
128
|
+
description:
|
|
129
|
+
'Block until a job reaches a terminal state (completed / failed / canceled) or the timeout expires. ' +
|
|
130
|
+
'Returns the current job view either way; check state to tell which happened.',
|
|
131
|
+
parameters: {
|
|
132
|
+
type: 'object',
|
|
133
|
+
properties: {
|
|
134
|
+
id: { type: 'string', description: 'Job id such as J#1042.' },
|
|
135
|
+
timeoutSeconds: {
|
|
136
|
+
type: 'number',
|
|
137
|
+
description: 'How long to wait. Default 60, maximum 600.',
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
required: ['id'],
|
|
141
|
+
additionalProperties: false,
|
|
142
|
+
},
|
|
143
|
+
async execute(args) {
|
|
144
|
+
const seconds = Math.min(Math.max(Number(args.timeoutSeconds ?? 60), 1), 600)
|
|
145
|
+
try {
|
|
146
|
+
const job = await queue.wait(args.id, seconds * 1000)
|
|
147
|
+
return { ok: true, job }
|
|
148
|
+
} catch (error) {
|
|
149
|
+
return fail(error)
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
for (const [toolName, method, verb] of [
|
|
155
|
+
['download_pause', 'pause', 'Pause'],
|
|
156
|
+
['download_resume', 'resume', 'Resume'],
|
|
157
|
+
['download_cancel', 'cancel', 'Cancel'],
|
|
158
|
+
]) {
|
|
159
|
+
tools.register({
|
|
160
|
+
name: toolName,
|
|
161
|
+
output: CANONICAL_OUTPUT,
|
|
162
|
+
description:
|
|
163
|
+
`${verb} a DLS job. Only the session that enqueued the job may do this while it is in flight; ` +
|
|
164
|
+
'finished jobs are open to everyone. A paused job keeps its partial file and can resume later.',
|
|
165
|
+
parameters: {
|
|
166
|
+
type: 'object',
|
|
167
|
+
properties: { id: { type: 'string', description: 'Job id such as J#1042.' } },
|
|
168
|
+
required: ['id'],
|
|
169
|
+
additionalProperties: false,
|
|
170
|
+
},
|
|
171
|
+
async execute(args, exec) {
|
|
172
|
+
try {
|
|
173
|
+
const job = await queue[method](args.id, withCaller(exec))
|
|
174
|
+
return { ok: true, job }
|
|
175
|
+
} catch (error) {
|
|
176
|
+
return fail(error)
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
tools.register({
|
|
183
|
+
name: 'download_cleanup',
|
|
184
|
+
output: CANONICAL_OUTPUT,
|
|
185
|
+
description:
|
|
186
|
+
'Clean up the calling session\'s DLS records and leftovers. Default scope "allWithoutFiles" removes ' +
|
|
187
|
+
'finished records and partial files but never touches the downloaded files themselves. Passing "all" ' +
|
|
188
|
+
'also deletes those files and cannot be undone.',
|
|
189
|
+
parameters: {
|
|
190
|
+
type: 'object',
|
|
191
|
+
properties: {
|
|
192
|
+
scope: {
|
|
193
|
+
type: 'string',
|
|
194
|
+
enum: CLEANUP_SCOPES,
|
|
195
|
+
description: `Default ${DEFAULT_SCOPE}. "all" is the only value that deletes downloaded files.`,
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
additionalProperties: false,
|
|
199
|
+
},
|
|
200
|
+
async execute(args, exec) {
|
|
201
|
+
try {
|
|
202
|
+
const result = await queue.cleanup(args?.scope ?? DEFAULT_SCOPE, withCaller(exec))
|
|
203
|
+
return { ok: true, ...result }
|
|
204
|
+
} catch (error) {
|
|
205
|
+
return fail(error)
|
|
206
|
+
}
|
|
207
|
+
},
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
mark('tools registered', '7/7')
|
|
211
|
+
} catch (error) {
|
|
212
|
+
markFailure('registerTools', error)
|
|
213
|
+
throw error
|
|
214
|
+
}
|
|
215
|
+
})
|
|
216
|
+
}
|
package/lib/transport.js
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process'
|
|
2
|
+
import { createWriteStream } from 'node:fs'
|
|
3
|
+
import fs from 'node:fs/promises'
|
|
4
|
+
import os from 'node:os'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { Readable } from 'node:stream'
|
|
7
|
+
import { pipeline } from 'node:stream/promises'
|
|
8
|
+
import { randomBytes } from 'node:crypto'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Transport layer. Two channels: an aria2c JSON-RPC daemon when one is
|
|
12
|
+
* available, and a plain Node fetch stream otherwise.
|
|
13
|
+
*
|
|
14
|
+
* The queue never talks to aria2 directly; it calls {@link Transport#transfer}.
|
|
15
|
+
*
|
|
16
|
+
* @typedef {object} TransferOptions
|
|
17
|
+
* @property {string[]} urls candidate sources, tried in order
|
|
18
|
+
* @property {string} destPath absolute final file path
|
|
19
|
+
* @property {number} [connections] aria2 split, default 16
|
|
20
|
+
* @property {number} [speedLimitKbps] 0 or absent means unlimited
|
|
21
|
+
* @property {string} [proxy] e.g. http://127.0.0.1:65532
|
|
22
|
+
* @property {number} [bytesDone] resume hint for the direct channel
|
|
23
|
+
* @property {(done: number, total: number) => void} [onProgress]
|
|
24
|
+
* @property {AbortSignal} [signal]
|
|
25
|
+
*
|
|
26
|
+
* @typedef {object} TransferResult
|
|
27
|
+
* @property {number} bytes
|
|
28
|
+
* @property {number} total -1 when the server never reported one
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const IS_WIN = process.platform === 'win32'
|
|
32
|
+
const ARIA2_BIN = IS_WIN ? 'aria2c.exe' : 'aria2c'
|
|
33
|
+
|
|
34
|
+
/** Absolute locations worth checking beyond PATH. */
|
|
35
|
+
function extraCandidates() {
|
|
36
|
+
if (IS_WIN) {
|
|
37
|
+
const local = process.env.LOCALAPPDATA ?? ''
|
|
38
|
+
const pf = process.env.ProgramFiles ?? 'C:\\Program Files'
|
|
39
|
+
const pf86 = process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)'
|
|
40
|
+
return [
|
|
41
|
+
path.join(pf, 'aria2', ARIA2_BIN),
|
|
42
|
+
path.join(pf86, 'aria2', ARIA2_BIN),
|
|
43
|
+
path.join(local, 'Programs', 'Motrix', 'resources', 'aria2', ARIA2_BIN),
|
|
44
|
+
path.join(local, 'Motrix', 'resources', 'aria2', ARIA2_BIN),
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
return ['/usr/bin/aria2c', '/usr/local/bin/aria2c', '/opt/homebrew/bin/aria2c']
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function isExecutable(file) {
|
|
51
|
+
try {
|
|
52
|
+
await fs.access(file, fs.constants.X_OK)
|
|
53
|
+
return true
|
|
54
|
+
} catch {
|
|
55
|
+
return false
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** @param {string} name */
|
|
60
|
+
async function findInPath(name) {
|
|
61
|
+
const dirs = (process.env.PATH ?? '').split(path.delimiter).filter(Boolean)
|
|
62
|
+
for (const dir of dirs) {
|
|
63
|
+
const candidate = path.join(dir, name)
|
|
64
|
+
if (await isExecutable(candidate)) return candidate
|
|
65
|
+
}
|
|
66
|
+
return undefined
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Locate an aria2c binary.
|
|
71
|
+
* Order: explicit config, environment, PATH, well-known install locations.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} [configured]
|
|
74
|
+
* @returns {Promise<string | undefined>}
|
|
75
|
+
*/
|
|
76
|
+
export async function findAria2(configured) {
|
|
77
|
+
if (configured) {
|
|
78
|
+
try {
|
|
79
|
+
await fs.access(configured, fs.constants.X_OK)
|
|
80
|
+
return configured
|
|
81
|
+
} catch {
|
|
82
|
+
/* fall through */
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
for (const key of ['DLS_ARIA2C_PATH', 'MT_ARIA2C_PATH', 'ARIA2C_PATH']) {
|
|
87
|
+
const value = process.env[key]
|
|
88
|
+
if (value) {
|
|
89
|
+
try {
|
|
90
|
+
await fs.access(value, fs.constants.X_OK)
|
|
91
|
+
return value
|
|
92
|
+
} catch {
|
|
93
|
+
/* fall through */
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const fromPath = await findInPath(ARIA2_BIN)
|
|
99
|
+
if (fromPath) return fromPath
|
|
100
|
+
|
|
101
|
+
for (const candidate of extraCandidates()) {
|
|
102
|
+
if (await isExecutable(candidate)) return candidate
|
|
103
|
+
}
|
|
104
|
+
return undefined
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Minimal JSON-RPC client for the aria2 daemon. */
|
|
108
|
+
class Aria2Rpc {
|
|
109
|
+
/** @param {number} port @param {string} secret */
|
|
110
|
+
constructor(port, secret) {
|
|
111
|
+
this.port = port
|
|
112
|
+
this.secret = secret
|
|
113
|
+
this.url = `http://127.0.0.1:${port}/jsonrpc`
|
|
114
|
+
this.nextId = 1
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** @param {string} method @param {unknown[]} params */
|
|
118
|
+
async call(method, params = []) {
|
|
119
|
+
const body = JSON.stringify({
|
|
120
|
+
jsonrpc: '2.0',
|
|
121
|
+
id: this.nextId++,
|
|
122
|
+
method,
|
|
123
|
+
params: [`token:${this.secret}`, ...params],
|
|
124
|
+
})
|
|
125
|
+
const response = await fetch(this.url, {
|
|
126
|
+
method: 'POST',
|
|
127
|
+
headers: { 'content-type': 'application/json' },
|
|
128
|
+
body,
|
|
129
|
+
})
|
|
130
|
+
const payload = await response.json()
|
|
131
|
+
if (payload.error) {
|
|
132
|
+
throw new Error(`aria2 ${method}: ${payload.error.message}`)
|
|
133
|
+
}
|
|
134
|
+
return payload.result
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Poll until the daemon answers, or give up. */
|
|
138
|
+
async waitReady(timeoutMs = 8000) {
|
|
139
|
+
const deadline = Date.now() + timeoutMs
|
|
140
|
+
while (Date.now() < deadline) {
|
|
141
|
+
try {
|
|
142
|
+
await this.call('aria2.getVersion')
|
|
143
|
+
return true
|
|
144
|
+
} catch {
|
|
145
|
+
await sleep(120)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return false
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function sleep(ms) {
|
|
153
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The transport facade. One instance per plugin; the daemon is shared by every
|
|
158
|
+
* concurrent job and torn down on dispose.
|
|
159
|
+
*/
|
|
160
|
+
export class Transport {
|
|
161
|
+
/**
|
|
162
|
+
* @param {{ aria2Path?: string, aria2Port?: number, connectionsPerJob?: number,
|
|
163
|
+
* speedLimitKbps?: number, proxy?: string }} config
|
|
164
|
+
* @param {(level: string, message: string) => void} [log]
|
|
165
|
+
*/
|
|
166
|
+
constructor(config = {}, log = () => {}) {
|
|
167
|
+
this.config = config
|
|
168
|
+
this.log = log
|
|
169
|
+
this.mode = 'direct'
|
|
170
|
+
this.aria2 = undefined
|
|
171
|
+
this.rpc = undefined
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Detect aria2 and start the daemon if possible. Never throws. */
|
|
175
|
+
async init() {
|
|
176
|
+
const binary = await findAria2(this.config.aria2Path)
|
|
177
|
+
if (!binary) {
|
|
178
|
+
this.log('info', 'aria2c not found, using the single-stream fallback')
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const port = this.config.aria2Port ?? 16801
|
|
183
|
+
const secret = randomBytes(12).toString('hex')
|
|
184
|
+
const args = [
|
|
185
|
+
'--enable-rpc',
|
|
186
|
+
`--rpc-listen-port=${port}`,
|
|
187
|
+
'--rpc-listen-all=false',
|
|
188
|
+
`--rpc-secret=${secret}`,
|
|
189
|
+
'--continue=true',
|
|
190
|
+
'--auto-file-renaming=false',
|
|
191
|
+
'--allow-overwrite=true',
|
|
192
|
+
'--log-level=warn',
|
|
193
|
+
'--console-log-level=warn',
|
|
194
|
+
'--summary-interval=0',
|
|
195
|
+
'--disable-ipv6=true',
|
|
196
|
+
]
|
|
197
|
+
if (this.config.proxy) args.push(`--all-proxy=${this.config.proxy}`)
|
|
198
|
+
|
|
199
|
+
try {
|
|
200
|
+
this.aria2 = spawn(binary, args, { windowsHide: true, stdio: 'ignore' })
|
|
201
|
+
this.aria2.unref()
|
|
202
|
+
this.aria2.on('exit', () => {
|
|
203
|
+
this.aria2 = undefined
|
|
204
|
+
this.mode = 'direct'
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
const rpc = new Aria2Rpc(port, secret)
|
|
208
|
+
if (await rpc.waitReady()) {
|
|
209
|
+
this.rpc = rpc
|
|
210
|
+
this.mode = 'aria2'
|
|
211
|
+
this.log('info', `aria2c ready on port ${port} (${binary})`)
|
|
212
|
+
} else {
|
|
213
|
+
this.log('warn', 'aria2c started but never answered; falling back')
|
|
214
|
+
this.aria2?.kill()
|
|
215
|
+
this.aria2 = undefined
|
|
216
|
+
}
|
|
217
|
+
} catch (error) {
|
|
218
|
+
this.log('warn', `aria2c failed to start: ${error.message}`)
|
|
219
|
+
this.mode = 'direct'
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** @param {TransferOptions} options @returns {Promise<TransferResult>} */
|
|
224
|
+
async transfer(options) {
|
|
225
|
+
if (this.mode === 'aria2' && this.rpc) {
|
|
226
|
+
try {
|
|
227
|
+
return await this.#transferAria2(options)
|
|
228
|
+
} catch (error) {
|
|
229
|
+
if (options.signal?.aborted) throw error
|
|
230
|
+
this.log('warn', `aria2 transfer failed, retrying single-stream: ${error.message}`)
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return this.#transferDirect(options)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** @param {TransferOptions} options */
|
|
237
|
+
async #transferAria2({ urls, destPath, connections, speedLimitKbps, onProgress, signal }) {
|
|
238
|
+
const rpc = this.rpc
|
|
239
|
+
await fs.mkdir(path.dirname(destPath), { recursive: true })
|
|
240
|
+
|
|
241
|
+
const options = {
|
|
242
|
+
dir: path.dirname(destPath),
|
|
243
|
+
out: path.basename(destPath),
|
|
244
|
+
split: String(connections ?? this.config.connectionsPerJob ?? 16),
|
|
245
|
+
'max-connection-per-server': String(connections ?? this.config.connectionsPerJob ?? 16),
|
|
246
|
+
'min-split-size': '1M',
|
|
247
|
+
continue: 'true',
|
|
248
|
+
'auto-file-renaming': 'false',
|
|
249
|
+
'allow-overwrite': 'true',
|
|
250
|
+
}
|
|
251
|
+
const limit = speedLimitKbps ?? this.config.speedLimitKbps ?? 0
|
|
252
|
+
if (limit > 0) options['max-download-limit'] = `${limit}K`
|
|
253
|
+
|
|
254
|
+
const gid = await rpc.call('aria2.addUri', [urls, options])
|
|
255
|
+
|
|
256
|
+
let aborted = false
|
|
257
|
+
const onAbort = () => {
|
|
258
|
+
aborted = true
|
|
259
|
+
void rpc.call('aria2.remove', [gid]).catch(() => {})
|
|
260
|
+
}
|
|
261
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
262
|
+
|
|
263
|
+
try {
|
|
264
|
+
for (;;) {
|
|
265
|
+
if (aborted) throw new Error('canceled')
|
|
266
|
+
const status = await rpc.call('aria2.tellStatus', [
|
|
267
|
+
gid,
|
|
268
|
+
['status', 'totalLength', 'completedLength', 'errorMessage'],
|
|
269
|
+
])
|
|
270
|
+
const done = Number(status.completedLength ?? 0)
|
|
271
|
+
const total = status.totalLength === '0' ? -1 : Number(status.totalLength)
|
|
272
|
+
onProgress?.(done, total)
|
|
273
|
+
|
|
274
|
+
if (status.status === 'complete') return { bytes: done, total }
|
|
275
|
+
if (status.status === 'error' || status.status === 'removed') {
|
|
276
|
+
throw new Error(status.errorMessage || `aria2 status ${status.status}`)
|
|
277
|
+
}
|
|
278
|
+
if (status.status === 'paused') return { bytes: done, total }
|
|
279
|
+
await sleep(400)
|
|
280
|
+
}
|
|
281
|
+
} finally {
|
|
282
|
+
signal?.removeEventListener('abort', onAbort)
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** @param {TransferOptions} options */
|
|
287
|
+
async #transferDirect({ urls, destPath, bytesDone = 0, onProgress, signal }) {
|
|
288
|
+
await fs.mkdir(path.dirname(destPath), { recursive: true })
|
|
289
|
+
const partPath = `${destPath}.part`
|
|
290
|
+
let lastError
|
|
291
|
+
|
|
292
|
+
for (const url of urls) {
|
|
293
|
+
try {
|
|
294
|
+
let offset = bytesDone
|
|
295
|
+
try {
|
|
296
|
+
offset = (await fs.stat(partPath)).size
|
|
297
|
+
} catch {
|
|
298
|
+
offset = bytesDone
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const headers = {}
|
|
302
|
+
if (offset > 0) headers.range = `bytes=${offset}-`
|
|
303
|
+
|
|
304
|
+
const response = await fetch(url, { headers, signal })
|
|
305
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
|
|
306
|
+
|
|
307
|
+
const ranged = response.status === 206
|
|
308
|
+
const start = ranged ? offset : 0
|
|
309
|
+
const declared = Number(response.headers.get('content-length') ?? 0)
|
|
310
|
+
const total = declared > 0 ? declared + start : -1
|
|
311
|
+
|
|
312
|
+
const sink = createWriteStream(partPath, { flags: ranged ? 'a' : 'w' })
|
|
313
|
+
let written = start
|
|
314
|
+
let lastTick = 0
|
|
315
|
+
|
|
316
|
+
const source = Readable.fromWeb(response.body)
|
|
317
|
+
source.on('data', (chunk) => {
|
|
318
|
+
written += chunk.length
|
|
319
|
+
const now = Date.now()
|
|
320
|
+
if (now - lastTick > 400) {
|
|
321
|
+
lastTick = now
|
|
322
|
+
onProgress?.(written, total)
|
|
323
|
+
}
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
await pipeline(source, sink)
|
|
327
|
+
onProgress?.(written, total)
|
|
328
|
+
await fs.rename(partPath, destPath)
|
|
329
|
+
return { bytes: written, total }
|
|
330
|
+
} catch (error) {
|
|
331
|
+
if (error?.name === 'AbortError' || signal?.aborted) throw error
|
|
332
|
+
lastError = error
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
throw lastError ?? new Error('no source available')
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** Stop the daemon. Safe to call twice. */
|
|
339
|
+
async dispose() {
|
|
340
|
+
try {
|
|
341
|
+
await this.rpc?.call('aria2.shutdown')
|
|
342
|
+
} catch {
|
|
343
|
+
/* daemon already gone */
|
|
344
|
+
}
|
|
345
|
+
this.rpc = undefined
|
|
346
|
+
this.mode = 'direct'
|
|
347
|
+
try {
|
|
348
|
+
this.aria2?.kill()
|
|
349
|
+
} catch {
|
|
350
|
+
/* ignore */
|
|
351
|
+
}
|
|
352
|
+
this.aria2 = undefined
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Exposed for tests: resolve a candidate without starting anything. */
|
|
357
|
+
export const _internal = { findInPath, extraCandidates, Aria2Rpc, sleep, homeDir: os.homedir }
|
package/lib/types.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DLS data model. Zero dependencies, plain JS.
|
|
3
|
+
*
|
|
4
|
+
* @typedef {'queued'|'running'|'paused'|'completed'|'failed'|'canceled'} JobState
|
|
5
|
+
* @typedef {'user'|'session_closed'|'host_restart'} PauseReason
|
|
6
|
+
*
|
|
7
|
+
* @typedef {object} JobRecord
|
|
8
|
+
* @property {string} id `J#1042`
|
|
9
|
+
* @property {number} seq monotonic sequence, matches the number in id
|
|
10
|
+
* @property {string} ownerWorkspace WorkspaceId (uuid)
|
|
11
|
+
* @property {string} ownerSession SessionId (uuid) of the parent session
|
|
12
|
+
* @property {string} [origin] subagent name, display and audit only
|
|
13
|
+
* @property {string[]} urls
|
|
14
|
+
* @property {string} destPath
|
|
15
|
+
* @property {JobState} state
|
|
16
|
+
* @property {PauseReason} [pauseReason]
|
|
17
|
+
* @property {number} createdAt
|
|
18
|
+
* @property {number} updatedAt
|
|
19
|
+
* @property {number} bytesTotal -1 when unknown
|
|
20
|
+
* @property {number} bytesCheckpoint checkpoint bytes, not live progress
|
|
21
|
+
* @property {number} attempts
|
|
22
|
+
* @property {string} [lastError]
|
|
23
|
+
* @property {string} [expectSha256]
|
|
24
|
+
* @property {number} [finalBytes]
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
export const JOB_STATES = Object.freeze([
|
|
28
|
+
'queued',
|
|
29
|
+
'running',
|
|
30
|
+
'paused',
|
|
31
|
+
'completed',
|
|
32
|
+
'failed',
|
|
33
|
+
'canceled',
|
|
34
|
+
])
|
|
35
|
+
|
|
36
|
+
const TERMINAL = new Set(['completed', 'failed', 'canceled'])
|
|
37
|
+
|
|
38
|
+
/** @param {JobState} state */
|
|
39
|
+
export function isTerminal(state) {
|
|
40
|
+
return TERMINAL.has(state)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** @param {number} seq */
|
|
44
|
+
export function formatJobId(seq) {
|
|
45
|
+
return `J#${seq}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const JOB_ID_RE = /^J#(\d+)$/
|
|
49
|
+
|
|
50
|
+
/** @param {string} id @returns {number | undefined} */
|
|
51
|
+
export function parseJobId(id) {
|
|
52
|
+
const m = JOB_ID_RE.exec(id)
|
|
53
|
+
return m ? Number(m[1]) : undefined
|
|
54
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-dl-server",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "DLS — DSH Download Server: a global, session-aware, persistent download queue for DeepSeek Harness.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "lib/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./lib/index.js",
|
|
9
|
+
"./cordis.patch.yml": "./cordis.patch.yml",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"lib",
|
|
14
|
+
"cordis.patch.yml",
|
|
15
|
+
"LICENSE",
|
|
16
|
+
"README.md",
|
|
17
|
+
"README.en.md"
|
|
18
|
+
],
|
|
19
|
+
"keywords": [
|
|
20
|
+
"deepseek-harness",
|
|
21
|
+
"dsh",
|
|
22
|
+
"dsh-plugin",
|
|
23
|
+
"download",
|
|
24
|
+
"downloader",
|
|
25
|
+
"queue",
|
|
26
|
+
"aria2",
|
|
27
|
+
"dls"
|
|
28
|
+
],
|
|
29
|
+
"author": "Jestions",
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"repository": {
|
|
32
|
+
"type": "git",
|
|
33
|
+
"url": "git+https://github.com/Jestions-dev/dsh-download-server.git"
|
|
34
|
+
},
|
|
35
|
+
"homepage": "https://github.com/Jestions-dev/dsh-download-server#readme",
|
|
36
|
+
"bugs": {
|
|
37
|
+
"url": "https://github.com/Jestions-dev/dsh-download-server/issues"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=20"
|
|
41
|
+
},
|
|
42
|
+
"scripts": {
|
|
43
|
+
"test": "node --test",
|
|
44
|
+
"check": "node --check lib/index.js && node --check lib/domain.js && node --check lib/types.js"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@deepseek-ai/cordis": "~4.1.0",
|
|
48
|
+
"@deepseek-ai/dsh-storage": "^0.2.2-rc.1",
|
|
49
|
+
"@deepseek-ai/dsh-storage-domain": "^0.2.2-rc.1",
|
|
50
|
+
"zod": "^4.4.3"
|
|
51
|
+
},
|
|
52
|
+
"dsh": {
|
|
53
|
+
"bundle": {
|
|
54
|
+
"patch": "./cordis.patch.yml"
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|