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/README.md +34 -7
- package/README.zh.md +34 -7
- package/lib/headers.js +138 -0
- package/lib/queue.js +894 -876
- package/lib/tools.js +16 -1
- package/lib/transport.js +67 -12
- package/lib/types.js +1 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -17,23 +17,32 @@ A persistent download queue for DeepSeek Harness.
|
|
|
17
17
|
Settings → Plugins → Install a third-party plugin → Add plugin
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
Type **`dsh-dl-server@0.
|
|
20
|
+
Type **`dsh-dl-server@0.4.0`**, confirm, then **restart DSH once**.
|
|
21
21
|
|
|
22
22
|
### From the command line
|
|
23
23
|
|
|
24
24
|
```sh
|
|
25
|
-
dsh plugin add dsh-dl-server@0.
|
|
25
|
+
dsh plugin add dsh-dl-server@0.4.0
|
|
26
26
|
```
|
|
27
27
|
|
|
28
28
|
> **Pin the version.** `0.1.0` declared `@deepseek-ai/dsh-storage-domain` as a regular dependency, so pnpm installed a second copy inside the plugin; the injected service then no longer matched the host and the plugin never mounted (every service stayed `pending`). `0.1.1` moves it to `peerDependencies`.
|
|
29
29
|
>
|
|
30
|
-
> pnpm also caches registry metadata — without an explicit version you may still resolve
|
|
30
|
+
> pnpm also caches registry metadata — without an explicit version you may still resolve an older release.
|
|
31
31
|
|
|
32
32
|
> `dsh` must be on your PATH. The desktop build ships one at
|
|
33
33
|
> `…\DeepSeek Harness\resources\runtime\cli\bin\dsh.cmd`; if you have never added it, use the UI path.
|
|
34
34
|
|
|
35
35
|
**Requires DSH 0.2.1-rc.5 or newer.**
|
|
36
|
-
**aria2 is optional** — install it for 16-way segmented downloading
|
|
36
|
+
**aria2 is optional but strongly recommended** — install it for 16-way segmented downloading. Without it the plugin falls back to a single-stream transfer: same features, roughly a tenth of the speed. The plugin warns at startup and reports the reason through `download_status`, so a slow first install is not a mystery.
|
|
37
|
+
|
|
38
|
+
| Platform | Install aria2 |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| Windows | `winget install aria2.aria2` |
|
|
41
|
+
| Ubuntu / Debian | `sudo apt install aria2` |
|
|
42
|
+
| Fedora | `sudo dnf install aria2` |
|
|
43
|
+
| Arch | `sudo pacman -S aria2` |
|
|
44
|
+
| macOS | `brew install aria2` |
|
|
45
|
+
|
|
37
46
|
|
|
38
47
|
---
|
|
39
48
|
|
|
@@ -87,6 +96,22 @@ A download started in one session is visible to every other session — progress
|
|
|
87
96
|
|
|
88
97
|
> 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
98
|
|
|
99
|
+
## New in 0.4.0
|
|
100
|
+
|
|
101
|
+
| | |
|
|
102
|
+
| --- | --- |
|
|
103
|
+
| **Custom request headers** | `download_enqueue` takes an optional `headers` object — `Referer`, `Cookie`, `User-Agent`, anything the host wants. Sent on the origin probe, on every mirror probe, on the aria2 download and on the single-stream fallback. |
|
|
104
|
+
| **Validated at enqueue** | Line breaks, control characters, illegal names, more than 32 headers, and transport-owned names (`Range`, `Host`, `Content-Length`, …) are rejected with `header-invalid` **before** any bytes move. |
|
|
105
|
+
| **Redacted in output** | Values may be session cookies. Job output lists names only (`"requestHeaders": ["Referer"]`); the values stay in the transfer layer and the local store. |
|
|
106
|
+
|
|
107
|
+
Why it matters: some URLs are correct and still refuse to download. Bilibili's media CDN answers **403 to any request without `Referer: https://www.bilibili.com`** — no number of mirrors helps. This is the same reason `curl -H` exists.
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
download_enqueue urls=[...] dest=... headers={"Referer": "https://www.bilibili.com"}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Resume still works: headers are persisted with the job, so a download interrupted by a DSH restart sends them again.
|
|
114
|
+
|
|
90
115
|
## Example
|
|
91
116
|
|
|
92
117
|
```
|
|
@@ -120,7 +145,7 @@ The last row is the point: **on a per-connection-throttled host, segmentation be
|
|
|
120
145
|
- **Ownership is per session.** Any session on the machine can *see* every job; only the owner can mutate one.
|
|
121
146
|
- **`cleanup(scope="all")` deletes files.** The default `allWithoutFiles` only drops records.
|
|
122
147
|
- **Checkpoints live in DSH storage**, not as lock files dropped into your home directory — the plugin does not depend on `TMPDIR` or the working directory.
|
|
123
|
-
- **When aria2 is unavailable** the transport degrades to a single Node stream. No features lost, some speed lost.
|
|
148
|
+
- **When aria2 is unavailable** the transport degrades to a single Node stream. No features lost, some speed lost — and the plugin says so instead of staying quiet, because a silent 10× slowdown looks like a bug.
|
|
124
149
|
|
|
125
150
|
---
|
|
126
151
|
|
|
@@ -132,13 +157,15 @@ The last row is the point: **on a per-connection-throttled host, segmentation be
|
|
|
132
157
|
|
|
133
158
|
## Roadmap
|
|
134
159
|
|
|
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:
|
|
160
|
+
The roadmap names product stages (`v1`, `v1.1`, `v1.2`, `v1.3`, `v2`); npm versions follow semver. They do not map one-to-one — use this table:
|
|
136
161
|
|
|
137
162
|
| npm | Roadmap | Content | Status |
|
|
138
163
|
| --- | --- | --- | --- |
|
|
139
164
|
| `0.1.x` | v1 | queue + persistence + seven tools + aria2 transport | released |
|
|
140
165
|
| `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 |
|
|
166
|
+
| `0.3.x` | v1.2 | queue de-duplication / mirror self-verification | released |
|
|
167
|
+
| `0.4.x` | v1.3 | custom request headers (`Referer` / `Cookie` / `User-Agent`) on every attempt, validated at enqueue, redacted in output | released |
|
|
168
|
+
| `0.4.1` | patch | missing aria2 is now a loud, actionable warning instead of a silent speed cliff; `download_status` reports the channel | released |
|
|
142
169
|
| `1.0.0` | v2 | shell interception / torrents / own segmented engine / content addressing | planned |
|
|
143
170
|
| `1.1.x` | v3 | user panel + callbacks into the agent | planned |
|
|
144
171
|
|
package/README.zh.md
CHANGED
|
@@ -17,23 +17,32 @@
|
|
|
17
17
|
设置 → 插件 → 安装第三方插件 → 添加插件
|
|
18
18
|
```
|
|
19
19
|
|
|
20
|
-
输入框里填 **`dsh-dl-server@0.
|
|
20
|
+
输入框里填 **`dsh-dl-server@0.4.0`**,确认,然后**重启一次 DSH**。
|
|
21
21
|
|
|
22
22
|
### 方式二:命令行
|
|
23
23
|
|
|
24
24
|
```sh
|
|
25
|
-
dsh plugin add dsh-dl-server@0.
|
|
25
|
+
dsh plugin add dsh-dl-server@0.4.0
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
>
|
|
28
|
+
> **带上版本号。** `0.1.0` 把 `@deepseek-ai/dsh-storage-domain` 声明成了普通依赖,pnpm 会给插件装第二份,服务和宿主对不上、插件挂不住(全部 `pending`)。0.1.1 改成 `peerDependencies` 已修复。
|
|
29
29
|
>
|
|
30
|
-
> 另外 pnpm 会缓存 registry 元数据 ——
|
|
30
|
+
> 另外 pnpm 会缓存 registry 元数据 —— 不带版本号时可能仍然解析到旧版本。
|
|
31
31
|
|
|
32
32
|
> `dsh` 需要先在 PATH 里。桌面版自带的那份在
|
|
33
33
|
> `…\DeepSeek Harness\resources\runtime\cli\bin\dsh.cmd`,没加过 PATH 的话用方式一。
|
|
34
34
|
|
|
35
35
|
**要求 DSH 0.2.1 或以上。**
|
|
36
|
-
**aria2
|
|
36
|
+
**aria2 可选,但强烈建议装** —— 装了走 16 线程分段;没装走单流兜底,功能一个不少,速度大约只有十分之一。没装时插件会在启动日志里明确警告,`download_status` 也会报出原因,不会让你以为"DLS 就这速度"。
|
|
37
|
+
|
|
38
|
+
| 平台 | 装 aria2 |
|
|
39
|
+
| --- | --- |
|
|
40
|
+
| Windows | `winget install aria2.aria2` |
|
|
41
|
+
| Ubuntu / Debian | `sudo apt install aria2` |
|
|
42
|
+
| Fedora | `sudo dnf install aria2` |
|
|
43
|
+
| Arch | `sudo pacman -S aria2` |
|
|
44
|
+
| macOS | `brew install aria2` |
|
|
45
|
+
|
|
37
46
|
|
|
38
47
|
---
|
|
39
48
|
|
|
@@ -87,6 +96,22 @@ DLS 做的是**队列层**,不是传输层。
|
|
|
87
96
|
|
|
88
97
|
> 镜像是第三方,它给什么我们不知道。长度 + ETag 能挡住「缓存了旧版本」「返回了错误页」这类问题,**挡不住投毒** —— 那只有 `sha256` 做得到。所以镜像只是兜底,真要保证内容就对 `sha256`。
|
|
89
98
|
|
|
99
|
+
## 0.4.0 新增
|
|
100
|
+
|
|
101
|
+
| | |
|
|
102
|
+
| --- | --- |
|
|
103
|
+
| **自定义请求头** | `download_enqueue` 多了个可选 `headers` —— `Referer` / `Cookie` / `User-Agent`,站点要什么给什么。原始探测、每个镜像探测、aria2 下载、单流兜底,四处都会带 |
|
|
104
|
+
| **入队时就校验** | 换行、控制符、非法名字、超过 32 条、以及传输层自己管的(`Range` / `Host` / `Content-Length` …)一律报 `header-invalid`,**一个字节都不会动** |
|
|
105
|
+
| **输出脱敏** | 值可能是登录 cookie。任务输出只列名字(`"requestHeaders": ["Referer"]`),值只留在传输层和本地存储里 |
|
|
106
|
+
|
|
107
|
+
**为什么需要它**:有些 URL 完全正确就是下不了。B站媒体 CDN 对**不带 `Referer: https://www.bilibili.com` 的请求一律 403**,镜像再多也没用 —— 这就是 `curl -H` 存在的理由。
|
|
108
|
+
|
|
109
|
+
```
|
|
110
|
+
download_enqueue urls=[...] dest=... headers={"Referer": "https://www.bilibili.com"}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
**断点续传照样能用**:header 跟 job 一起持久化,DSH 重启后接着下仍然会带上。
|
|
114
|
+
|
|
90
115
|
## 用它
|
|
91
116
|
|
|
92
117
|
```
|
|
@@ -132,13 +157,15 @@ agent 会调 `download_enqueue` 两次拿到 `J#10` / `J#11`,然后 `download_
|
|
|
132
157
|
|
|
133
158
|
## 路线
|
|
134
159
|
|
|
135
|
-
路线图用 `v1 / v1.1 / v1.2 / v2` 讲产品阶段,npm 版本按语义化版本走。两者对不上一一对应,查这张表:
|
|
160
|
+
路线图用 `v1 / v1.1 / v1.2 / v1.3 / v2` 讲产品阶段,npm 版本按语义化版本走。两者对不上一一对应,查这张表:
|
|
136
161
|
|
|
137
162
|
| npm 版本 | 路线图 | 内容 | 状态 |
|
|
138
163
|
| --- | --- | --- | --- |
|
|
139
164
|
| `0.1.x` | v1 | 队列 + 持久化 + 七个工具 + aria2 传输 | 已发布 |
|
|
140
165
|
| `0.2.x` | v1.1 | 镜像表 / 镜像冷却 / 并发分档 / 会话公平 / 磁盘余量 / 失败显式化 | 已发布 |
|
|
141
|
-
| `0.3.x` | v1.2 | 队列去重 / 镜像自证同源 |
|
|
166
|
+
| `0.3.x` | v1.2 | 队列去重 / 镜像自证同源 | 已发布 |
|
|
167
|
+
| `0.4.x` | v1.3 | 自定义请求头(`Referer` / `Cookie` / `User-Agent`):每次尝试都带,入队时校验,输出只列名字 | 已发布 |
|
|
168
|
+
| `0.4.1` | 补丁 | 没装 aria2 从"静默变慢"改成"明确警告 + 安装命令";`download_status` 能看出走的哪条通道 | 已发布 |
|
|
142
169
|
| `1.0.0` | v2 | 拦截 shell 下载 / 种子与磁力 / 自研分段引擎 / 内容寻址 | 计划 |
|
|
143
170
|
| `1.1.x` | v3 | 用户面板 + 操作回传 agent | 计划 |
|
|
144
171
|
|
package/lib/headers.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Custom HTTP request headers for a download job.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists (v1.3): some CDNs refuse a bare GET. Bilibili's media CDN
|
|
5
|
+
* answers 403 unless the request carries `Referer: https://www.bilibili.com`;
|
|
6
|
+
* private endpoints want `Cookie`; some hosts sniff `User-Agent`. Without a way
|
|
7
|
+
* to send headers, a whole class of direct links is undownloadable no matter how
|
|
8
|
+
* many mirrors are offered.
|
|
9
|
+
*
|
|
10
|
+
* What this module owns:
|
|
11
|
+
* · validation — a bad header must fail at enqueue time, not mid-transfer
|
|
12
|
+
* · the aria2 shape — `aria2.addUri` wants `["Name: Value", ...]`, not an object
|
|
13
|
+
* · redaction — values may be cookies or tokens, so output lists names only
|
|
14
|
+
*
|
|
15
|
+
* SPEC §11.3.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** RFC 7230 token: what a header name may legally contain. */
|
|
19
|
+
const NAME_RE = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Headers the transfer layer already owns, or that would let a caller break it.
|
|
23
|
+
* Rejecting these is not paranoia: a user-supplied `Range` silently corrupts
|
|
24
|
+
* resume, and `Host`/`Content-Length` desync aria2's accounting.
|
|
25
|
+
*/
|
|
26
|
+
const RESERVED = new Set([
|
|
27
|
+
'host',
|
|
28
|
+
'content-length',
|
|
29
|
+
'transfer-encoding',
|
|
30
|
+
'connection',
|
|
31
|
+
'content-encoding',
|
|
32
|
+
'range',
|
|
33
|
+
'accept-encoding',
|
|
34
|
+
])
|
|
35
|
+
|
|
36
|
+
/** Upper bounds, so one job cannot smuggle an unbounded blob into the store. */
|
|
37
|
+
const MAX_HEADERS = 32
|
|
38
|
+
const MAX_NAME_LEN = 64
|
|
39
|
+
const MAX_VALUE_LEN = 4096
|
|
40
|
+
|
|
41
|
+
export class HeaderError extends Error {
|
|
42
|
+
/** @param {string} message */
|
|
43
|
+
constructor(message) {
|
|
44
|
+
super(message)
|
|
45
|
+
this.name = 'HeaderError'
|
|
46
|
+
this.code = 'header-invalid'
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Validate and copy a caller-supplied header map.
|
|
52
|
+
*
|
|
53
|
+
* @param {unknown} input `{ "Referer": "https://www.bilibili.com" }`
|
|
54
|
+
* @returns {Record<string, string>} a fresh plain object, empty when `input` is empty
|
|
55
|
+
* @throws {HeaderError} on anything the transport could not send faithfully
|
|
56
|
+
*/
|
|
57
|
+
export function normalizeHeaders(input) {
|
|
58
|
+
if (input === undefined || input === null) return {}
|
|
59
|
+
if (typeof input !== 'object' || Array.isArray(input)) {
|
|
60
|
+
throw new HeaderError('headers must be an object of name/value strings')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const entries = Object.entries(input)
|
|
64
|
+
if (entries.length === 0) return {}
|
|
65
|
+
if (entries.length > MAX_HEADERS) {
|
|
66
|
+
throw new HeaderError(`too many headers (${entries.length}); at most ${MAX_HEADERS} are supported`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** @type {Record<string, string>} */
|
|
70
|
+
const out = {}
|
|
71
|
+
const seen = new Set()
|
|
72
|
+
|
|
73
|
+
for (const [rawName, rawValue] of entries) {
|
|
74
|
+
const name = rawName.trim()
|
|
75
|
+
if (!name) throw new HeaderError('header name must not be empty')
|
|
76
|
+
if (name.length > MAX_NAME_LEN) {
|
|
77
|
+
throw new HeaderError(`header name too long: ${name.slice(0, 32)}…`)
|
|
78
|
+
}
|
|
79
|
+
if (!NAME_RE.test(name)) {
|
|
80
|
+
throw new HeaderError(`invalid header name: ${JSON.stringify(name)}`)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const lower = name.toLowerCase()
|
|
84
|
+
if (RESERVED.has(lower)) {
|
|
85
|
+
throw new HeaderError(
|
|
86
|
+
`header ${name} is managed by the downloader and cannot be set per job`,
|
|
87
|
+
)
|
|
88
|
+
}
|
|
89
|
+
if (seen.has(lower)) {
|
|
90
|
+
throw new HeaderError(`duplicate header: ${name} (names are case-insensitive)`)
|
|
91
|
+
}
|
|
92
|
+
seen.add(lower)
|
|
93
|
+
|
|
94
|
+
if (typeof rawValue !== 'string') {
|
|
95
|
+
throw new HeaderError(`header ${name} must have a string value`)
|
|
96
|
+
}
|
|
97
|
+
// \r\n would let a value forge extra header lines; other control characters
|
|
98
|
+
// are either illegal on the wire or mangled by a proxy.
|
|
99
|
+
if (/[\r\n]/.test(rawValue)) {
|
|
100
|
+
throw new HeaderError(`header ${name} contains a line break`)
|
|
101
|
+
}
|
|
102
|
+
// eslint-disable-next-line no-control-regex
|
|
103
|
+
if (/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/.test(rawValue)) {
|
|
104
|
+
throw new HeaderError(`header ${name} contains a control character`)
|
|
105
|
+
}
|
|
106
|
+
if (rawValue.length > MAX_VALUE_LEN) {
|
|
107
|
+
throw new HeaderError(`header ${name} value too long (${rawValue.length} > ${MAX_VALUE_LEN})`)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
out[name] = rawValue
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return out
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* aria2's JSON-RPC takes headers as a flat list of `"Name: Value"` strings and
|
|
118
|
+
* sends each one with `--header`. An empty list must stay absent, not `[]`.
|
|
119
|
+
* @param {Record<string, string> | undefined} headers
|
|
120
|
+
* @returns {string[] | undefined}
|
|
121
|
+
*/
|
|
122
|
+
export function toAria2Headers(headers) {
|
|
123
|
+
if (!headers) return undefined
|
|
124
|
+
const list = Object.entries(headers).map(([name, value]) => `${name}: ${value}`)
|
|
125
|
+
return list.length > 0 ? list : undefined
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The names alone, for anything a human or a model will read.
|
|
130
|
+
* Values never leave the transfer layer — they may be cookies or tokens.
|
|
131
|
+
* @param {Record<string, string> | undefined} headers
|
|
132
|
+
* @returns {string[] | undefined}
|
|
133
|
+
*/
|
|
134
|
+
export function headerNames(headers) {
|
|
135
|
+
if (!headers) return undefined
|
|
136
|
+
const names = Object.keys(headers)
|
|
137
|
+
return names.length > 0 ? names : undefined
|
|
138
|
+
}
|