@rosmeowtis/dsh-aria2-rpc 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +28 -2
- package/client.js +192 -0
- package/cordis.patch.yml +3 -0
- package/icon.svg +1 -0
- package/lib/src/connection-test.d.ts +2 -0
- package/lib/src/connection-test.js +42 -0
- package/lib/src/events.d.ts +45 -0
- package/lib/src/events.js +98 -0
- package/lib/src/index.d.ts +18 -0
- package/lib/src/index.js +172 -0
- package/lib/src/rpc.d.ts +117 -0
- package/lib/src/rpc.js +363 -0
- package/locale/en.json +1 -0
- package/locale/zh.json +1 -0
- package/package.json +66 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 dsh-aria2-rpc contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,29 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @rosmeowtis/dsh-aria2-rpc
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
DSH 插件:连接**已有的 aria2 JSON-RPC 服务**(HTTP,`/jsonrpc`),为模型提供四个工具。不携带、不启动、不管理 aria2。
|
|
4
|
+
|
|
5
|
+
| 工具 | 作用 |
|
|
6
|
+
| --- | --- |
|
|
7
|
+
| `aria2_download` | 提交单文件直链下载,立即返回 GID;可选保存目录、文件名、来源提供的校验和(sha-256 / sha-1 / md5,实际计算由 aria2 执行)、任务级 HTTP 代理。 |
|
|
8
|
+
| `aria2_status` | 读取任务状态、进度、速度、文件路径与脱敏错误。 |
|
|
9
|
+
| `aria2_list` | 列出 active / waiting / stopped 或全部任务;只读,支持 limit/offset。 |
|
|
10
|
+
| `aria2_control` | `pause` / `resume` / `cancel`。取消保留部分文件,不关闭 aria2。 |
|
|
11
|
+
|
|
12
|
+
> 工具定义的上下文占用: ~400 token。
|
|
13
|
+
|
|
14
|
+
配置:设置 → 插件 → Aria2 RPC,可设置 `rpcHost`、`rpcPort`、密钥与请求超时;密钥固定使用凭据 `ARIA2_RPC_SECRET`,无密钥时不发送;HTTP 不加密,远程连接请仅在可信网络或加密隧道中使用。
|
|
15
|
+
|
|
16
|
+
设置页的「测试连接」使用当前输入的主机、端口与超时调用 `aria2.getVersion`,无需先保存。密钥框中有内容时使用该临时值,否则从当前凭据引用读取;测试不保存配置、不创建任务,成功显示 aria2 版本,失败显示连通性、超时或鉴权提示。
|
|
17
|
+
|
|
18
|
+
下载完成或失败时,插件通过 aria2 的 WebSocket 通知向发起会话推送一条消息。只跟踪本进程内由 `aria2_download` 创建的任务;插件重载后,重载前创建的任务不再推送。
|
|
19
|
+
|
|
20
|
+
创建与控制仅在退出计划模式后的完全访问模式执行;状态读取不受此限。结果未知的修改请求会返回可查询的 GID,不会自动重复提交。
|
|
21
|
+
|
|
22
|
+
## 开发
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
npm install
|
|
26
|
+
npm test # 构建 + 语法检查 + 模拟 RPC 单元测试(不真实下载)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
安装到当前 profile:DSH 的插件管理 `install_bundle`,target 为本目录绝对路径。
|
package/client.js
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: '@rosmeowtis/dsh-aria2-rpc',
|
|
3
|
+
factory(require) {
|
|
4
|
+
const React = require('react');
|
|
5
|
+
const h = React.createElement;
|
|
6
|
+
const NS = 'aria2-rpc';
|
|
7
|
+
const dictionaries = {
|
|
8
|
+
zh: {
|
|
9
|
+
host: 'RPC 主机', port: 'RPC 端口', timeout: '请求超时(毫秒)',
|
|
10
|
+
hostHint: '当前使用 HTTP,请确保在可信网络下使用。',
|
|
11
|
+
save: '保存连接设置', saving: '保存中…', saved: '连接设置已保存。',
|
|
12
|
+
rejected: '保存失败或配置已在别处变更。草稿已保留;请重新打开页面核对后再保存。',
|
|
13
|
+
readOnly: '配置不可用或当前连接不允许写入。',
|
|
14
|
+
secret: 'RPC 密钥', configured: '已配置', missing: '未配置', unknown: '状态未知',
|
|
15
|
+
secretHint: '仅写入 DSH 凭据存储,不回显原值;留空不改变。清除存储值后,环境变量等来源仍可能生效。',
|
|
16
|
+
saveSecret: '保存密钥', clearSecret: '清除存储的密钥', secretSaved: '密钥操作已完成。',
|
|
17
|
+
secretFailed: '凭据操作失败,请检查连接权限;已保存的设置不受影响。',
|
|
18
|
+
details: '连接现有 aria2 RPC 服务,不自带 aria2,提供创建、查询、暂停、恢复、取消功能。',
|
|
19
|
+
test: '测试连接', testing: '测试中…', connected: '连接成功,aria2 版本:',
|
|
20
|
+
testFailed: '测试失败:', NETWORK: '无法连接,请检查主机、端口及防火墙。', TIMEOUT: '请求超时。',
|
|
21
|
+
RPC_ERROR: 'RPC 拒绝请求,请检查密钥及服务配置。', HTTP_ERROR: 'HTTP 响应异常,请检查 RPC 端口。',
|
|
22
|
+
INVALID_INPUT: '主机、端口或超时格式不正确。', BAD_RESPONSE: '响应不是有效的 aria2 RPC 版本信息。',
|
|
23
|
+
REDIRECT: 'RPC 地址发生重定向,已拒绝。', RESPONSE_TOO_LARGE: 'RPC 响应过大。', ABORTED: '测试已取消。',
|
|
24
|
+
CREDENTIAL_ERROR: '无法读取凭据。', TRANSPORT_ERROR: '无法调用 DSH 测试接口,请刷新页面重试。',
|
|
25
|
+
},
|
|
26
|
+
en: {
|
|
27
|
+
host: 'RPC host', port: 'RPC port', timeout: 'Request timeout (ms)',
|
|
28
|
+
hostHint: 'Uses HTTP; use it only on a trusted network.',
|
|
29
|
+
save: 'Save connection settings', saving: 'Saving…', saved: 'Connection settings saved.',
|
|
30
|
+
rejected: 'Save failed or settings changed elsewhere. Your draft was kept; reopen this page to review before saving again.',
|
|
31
|
+
readOnly: 'Settings are unavailable or this connection is read-only.',
|
|
32
|
+
secret: 'RPC secret', configured: 'Configured', missing: 'Not configured', unknown: 'Unknown',
|
|
33
|
+
secretHint: 'Written only to DSH credentials, never read back. Blank keeps it unchanged. Environment values may still apply after removing the stored secret.',
|
|
34
|
+
saveSecret: 'Save secret', clearSecret: 'Remove stored secret', secretSaved: 'Credential operation completed.',
|
|
35
|
+
secretFailed: 'Credential operation failed. Check connection permissions; saved settings are unaffected.',
|
|
36
|
+
details: 'Connects to an existing aria2 RPC service without bundling aria2; provides create, query, pause, resume, and cancel.',
|
|
37
|
+
test: 'Test connection', testing: 'Testing…', connected: 'Connected, aria2 version: ',
|
|
38
|
+
testFailed: 'Connection test failed: ', NETWORK: 'Cannot connect. Check host, port and firewall.', TIMEOUT: 'Request timed out.',
|
|
39
|
+
RPC_ERROR: 'RPC rejected the request. Check the secret and service configuration.', HTTP_ERROR: 'Unexpected HTTP response. Check the RPC port.',
|
|
40
|
+
INVALID_INPUT: 'Invalid host, port or timeout.', BAD_RESPONSE: 'Not a valid aria2 RPC version response.',
|
|
41
|
+
REDIRECT: 'RPC redirect rejected.', RESPONSE_TOO_LARGE: 'RPC response is too large.', ABORTED: 'Test cancelled.',
|
|
42
|
+
CREDENTIAL_ERROR: 'Cannot read the credential.', TRANSPORT_ERROR: 'Cannot call the DSH test endpoint. Refresh and retry.',
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
const css = `
|
|
46
|
+
.aria2-rpc-form{display:flex;flex-direction:column;gap:16px;color:var(--dsw-alias-label-primary);font-size:14px;line-height:1.6;max-width:680px;overflow-wrap:anywhere}
|
|
47
|
+
.aria2-rpc-form label{display:flex;flex-direction:column;gap:8px;font-weight:500}
|
|
48
|
+
.aria2-rpc-form input{box-sizing:border-box;width:100%;min-height:40px;padding:8px 12px;border:1px solid var(--dsw-alias-border-l2);border-radius:8px;background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);font:inherit;font-weight:400}
|
|
49
|
+
.aria2-rpc-form input:focus-visible,.aria2-rpc-form button:focus-visible{outline:2px solid var(--dsw-alias-brand-primary);outline-offset:2px}
|
|
50
|
+
.aria2-rpc-form input::placeholder{color:var(--dsw-alias-state-idle-primary);opacity:1}
|
|
51
|
+
.aria2-rpc-form p{margin:0;color:var(--dsw-alias-label-secondary);font-weight:400}
|
|
52
|
+
.aria2-rpc-form .aria2-rpc-pair{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
|
53
|
+
.aria2-rpc-form button{min-height:40px;padding:8px 14px;border-radius:8px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-primary);font:inherit;cursor:pointer}
|
|
54
|
+
.aria2-rpc-form button:hover:not(:disabled){background:var(--dsw-alias-bg-layer-1)}
|
|
55
|
+
.aria2-rpc-form button:disabled,.aria2-rpc-form input:disabled{opacity:.5;cursor:default}
|
|
56
|
+
.aria2-rpc-form .aria2-rpc-actions{display:flex;flex-wrap:wrap;gap:8px;align-items:center}
|
|
57
|
+
.aria2-rpc-form .aria2-rpc-error{color:var(--dsw-alias-state-error-primary)}
|
|
58
|
+
.aria2-rpc-form .aria2-rpc-note{background:var(--dsw-alias-bg-layer-2);border-radius:8px;padding:10px 12px;font-size:13px}
|
|
59
|
+
.aria2-rpc-form .aria2-rpc-secret-head{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
|
60
|
+
.aria2-rpc-form .aria2-rpc-secret-head label{display:block;font-weight:500}
|
|
61
|
+
.aria2-rpc-form .aria2-rpc-status{display:inline-flex;align-items:center;padding:2px 8px;border-radius:999px;font-size:12px;font-weight:500;line-height:1.5}
|
|
62
|
+
.aria2-rpc-form .aria2-rpc-status.configured{background:#15803d;color:#fff}
|
|
63
|
+
.aria2-rpc-form .aria2-rpc-status.missing{background:var(--dsw-alias-bg-layer-2);color:var(--dsw-alias-label-secondary)}
|
|
64
|
+
.aria2-rpc-form .aria2-rpc-test.success{background:#15803d;border-color:#15803d;color:#fff}
|
|
65
|
+
.aria2-rpc-form .aria2-rpc-test.failure{background:#b91c1c;border-color:#b91c1c;color:#fff}
|
|
66
|
+
.aria2-rpc-form .aria2-rpc-test.success:hover:not(:disabled){background:#116932}
|
|
67
|
+
.aria2-rpc-form .aria2-rpc-test.failure:hover:not(:disabled){background:#991b1b}
|
|
68
|
+
.aria2-rpc-form fieldset{margin:0;padding:16px 0 0;border:0;border-top:1px solid var(--dsw-alias-border-l1);display:flex;flex-direction:column;gap:12px;min-width:0}
|
|
69
|
+
@media(max-width:520px){.aria2-rpc-form .aria2-rpc-pair{grid-template-columns:1fr}}
|
|
70
|
+
`;
|
|
71
|
+
return {
|
|
72
|
+
inject: ['slots', 'locale', 'remote', 'remote.credentials'],
|
|
73
|
+
apply(ctx) {
|
|
74
|
+
for (const [lang, dictionary] of Object.entries(dictionaries)) ctx.effect(() => ctx.locale.register(NS, lang, dictionary));
|
|
75
|
+
const t = ctx.locale.bind(NS);
|
|
76
|
+
ctx.inject(['configForms'], ready => {
|
|
77
|
+
const form = ready.configForms.get('aria2-rpc');
|
|
78
|
+
function Settings() {
|
|
79
|
+
React.useSyncExternalStore(fn => ctx.locale.subscribe(fn), () => ctx.locale.getSnapshot());
|
|
80
|
+
const state = React.useSyncExternalStore(fn => form.subscribe(fn), () => form.getSnapshot());
|
|
81
|
+
const [draft, setDraft] = React.useState(null);
|
|
82
|
+
const [busy, setBusy] = React.useState(false);
|
|
83
|
+
const [testing, setTesting] = React.useState(false);
|
|
84
|
+
const [testState, setTestState] = React.useState('idle');
|
|
85
|
+
const testAbort = React.useRef(null);
|
|
86
|
+
React.useEffect(() => () => testAbort.current?.abort(), []);
|
|
87
|
+
const [notice, setNotice] = React.useState('');
|
|
88
|
+
const [failed, setFailed] = React.useState(false);
|
|
89
|
+
const [secret, setSecret] = React.useState('');
|
|
90
|
+
const [credential, setCredential] = React.useState(null);
|
|
91
|
+
const values = state.value || {};
|
|
92
|
+
const ref = values.rpcSecretRef || 'ARIA2_RPC_SECRET';
|
|
93
|
+
const writable = state.status === 'ready' && state.writable;
|
|
94
|
+
const disabled = busy || !writable;
|
|
95
|
+
const remote = () => ctx.remote.credentials;
|
|
96
|
+
const refreshCredential = async () => {
|
|
97
|
+
const result = await remote().describe([ref]);
|
|
98
|
+
if (!result.ok) throw new Error('credential unavailable');
|
|
99
|
+
return result.value[ref];
|
|
100
|
+
};
|
|
101
|
+
React.useEffect(() => {
|
|
102
|
+
let active = true;
|
|
103
|
+
setCredential(null);
|
|
104
|
+
setSecret('');
|
|
105
|
+
refreshCredential().then(value => { if (active) setCredential(value); }).catch(() => {});
|
|
106
|
+
return () => { active = false; };
|
|
107
|
+
}, [ref]);
|
|
108
|
+
const edit = (key, value) => {
|
|
109
|
+
setDraft(old => ({ revision: old ? old.revision : state.revision, values: { ...(old ? old.values : {}), [key]: value } }));
|
|
110
|
+
setNotice(''); setTestState('idle');
|
|
111
|
+
};
|
|
112
|
+
const save = async event => {
|
|
113
|
+
event.preventDefault();
|
|
114
|
+
if (!draft) return;
|
|
115
|
+
setBusy(true); setNotice(''); setFailed(false);
|
|
116
|
+
try {
|
|
117
|
+
const ops = Object.entries(draft.values).map(([key, value]) => ({ op: 'set', path: [key], value: key === 'rpcPort' || key === 'rpcTimeoutMs' ? Number(value) : value }));
|
|
118
|
+
if (!await form.mutate(ops, draft.revision)) throw new Error('settings refused');
|
|
119
|
+
setDraft(null); setNotice(t('saved'));
|
|
120
|
+
} catch { setFailed(true); setNotice(t('rejected')); }
|
|
121
|
+
finally { setBusy(false); }
|
|
122
|
+
};
|
|
123
|
+
const writeSecret = async clear => {
|
|
124
|
+
setBusy(true); setNotice(''); setFailed(false);
|
|
125
|
+
try {
|
|
126
|
+
const result = clear ? await remote().unset(ref) : await remote().set(ref, secret);
|
|
127
|
+
if (!result.ok) throw new Error('credential refused');
|
|
128
|
+
setSecret('');
|
|
129
|
+
setCredential(await refreshCredential());
|
|
130
|
+
setNotice(t('secretSaved'));
|
|
131
|
+
} catch { setFailed(true); setNotice(t('secretFailed')); }
|
|
132
|
+
finally { setBusy(false); }
|
|
133
|
+
};
|
|
134
|
+
const testConnection = async () => {
|
|
135
|
+
const current = { ...values, ...(draft ? draft.values : {}) };
|
|
136
|
+
const controller = new AbortController();
|
|
137
|
+
testAbort.current = controller;
|
|
138
|
+
setTesting(true); setTestState('testing'); setBusy(true); setNotice(''); setFailed(false);
|
|
139
|
+
try {
|
|
140
|
+
const response = await fetch('./api/aria2-rpc.test', {
|
|
141
|
+
method: 'POST', credentials: 'same-origin', headers: { 'content-type': 'application/json' },
|
|
142
|
+
signal: controller.signal,
|
|
143
|
+
body: JSON.stringify({ rpcHost: current.rpcHost, rpcPort: Number(current.rpcPort), rpcTimeoutMs: Number(current.rpcTimeoutMs), rpcSecretRef: current.rpcSecretRef, ...(secret ? { secret } : {}) }),
|
|
144
|
+
});
|
|
145
|
+
const result = await response.json();
|
|
146
|
+
if (controller.signal.aborted) return;
|
|
147
|
+
if (response.ok && result.ok === true && typeof result.version === 'string') { setTestState('success'); setNotice(t('connected') + result.version); }
|
|
148
|
+
else {
|
|
149
|
+
const code = ['NETWORK', 'TIMEOUT', 'RPC_ERROR', 'HTTP_ERROR', 'INVALID_INPUT', 'BAD_RESPONSE', 'REDIRECT', 'RESPONSE_TOO_LARGE', 'ABORTED', 'CREDENTIAL_ERROR'].includes(result.code) ? result.code : 'TRANSPORT_ERROR';
|
|
150
|
+
setTestState('failure'); setFailed(true); setNotice(t('testFailed') + t(code));
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
if (!controller.signal.aborted) { setTestState('failure'); setFailed(true); setNotice(t('testFailed') + t('TRANSPORT_ERROR')); }
|
|
154
|
+
} finally {
|
|
155
|
+
if (!controller.signal.aborted) { setTesting(false); setBusy(false); }
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
const placeholders = { rpcHost: '127.0.0.1', rpcPort: '6800', rpcTimeoutMs: '10000' };
|
|
159
|
+
const field = (key, label, fallback, type, props = {}) => h('label', { key, htmlFor: `aria2-${key}` }, t(label), h('input', {
|
|
160
|
+
id: `aria2-${key}`, type, placeholder: placeholders[key], value: draft && key in draft.values ? draft.values[key] : values[key] ?? fallback,
|
|
161
|
+
disabled, onChange: e => edit(key, e.target.value), required: true, spellCheck: false, ...props,
|
|
162
|
+
}));
|
|
163
|
+
return h('div', { className: 'aria2-rpc-form' },
|
|
164
|
+
h('style', null, css),
|
|
165
|
+
h('p', { className: 'aria2-rpc-note' }, t('details')),
|
|
166
|
+
!writable && h('p', { role: 'status' }, t('readOnly')),
|
|
167
|
+
h('form', { onSubmit: save, className: 'aria2-rpc-form' },
|
|
168
|
+
field('rpcHost', 'host', '127.0.0.1', 'text', { pattern: '[A-Za-z0-9][A-Za-z0-9.\\-]*' }),
|
|
169
|
+
h('p', { className: 'aria2-rpc-note' }, t('hostHint')),
|
|
170
|
+
h('div', { className: 'aria2-rpc-pair' }, field('rpcPort', 'port', 6800, 'number', { min: 1, max: 65535, step: 1 }), field('rpcTimeoutMs', 'timeout', 10000, 'number', { min: 1, max: 60000, step: 1 })),
|
|
171
|
+
h('div', { className: 'aria2-rpc-actions' }, h('button', { type: 'submit', disabled: disabled || !draft }, t(busy ? 'saving' : 'save'))),
|
|
172
|
+
),
|
|
173
|
+
h('fieldset', { disabled: disabled || !credential || !credential.writable || !!draft },
|
|
174
|
+
h('div', { className: 'aria2-rpc-secret-head' },
|
|
175
|
+
h('label', { htmlFor: 'aria2-secret' }, t('secret')),
|
|
176
|
+
h('span', { className: `aria2-rpc-status ${credential && credential.configured ? 'configured' : 'missing'}` }, t(credential ? credential.configured ? 'configured' : 'missing' : 'unknown'))),
|
|
177
|
+
h('input', { id: 'aria2-secret', type: 'password', autoComplete: 'new-password', placeholder: 'rpc-secret', value: secret, onChange: e => setSecret(e.target.value) }),
|
|
178
|
+
h('p', { className: 'aria2-rpc-note' }, t('secretHint')),
|
|
179
|
+
h('div', { className: 'aria2-rpc-actions' },
|
|
180
|
+
h('button', { type: 'button', disabled: !secret, onClick: () => writeSecret(false) }, t('saveSecret')),
|
|
181
|
+
h('button', { type: 'button', disabled: !credential || !credential.configured, onClick: () => writeSecret(true) }, t('clearSecret'))),
|
|
182
|
+
),
|
|
183
|
+
h('div', { className: 'aria2-rpc-actions' }, h('button', { type: 'button', className: `aria2-rpc-test ${testState === 'success' ? 'success' : testState === 'failure' ? 'failure' : ''}`, disabled, onClick: testConnection }, t(testing ? 'testing' : 'test'))),
|
|
184
|
+
notice && h('p', { role: failed ? 'alert' : 'status', className: `aria2-rpc-note${failed ? ' aria2-rpc-error' : ''}` }, notice),
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
ready.slots.inject('plugins.bundle.config', () => ready.slots.register({ name: 'plugins.bundle.config', key: '@rosmeowtis/dsh-aria2-rpc' }, Settings));
|
|
188
|
+
});
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
},
|
|
192
|
+
});
|
package/cordis.patch.yml
ADDED
package/icon.svg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#146b58"/><g fill="none" stroke="#fff" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"><path d="M32 14v26m-10-9 10 10 10-10M16 42v8h32v-8"/><path d="M14 20h6m24 0h6" opacity=".6"/></g></svg>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { credentialRef } from '@deepseek-ai/dsh-credentials';
|
|
2
|
+
import { Aria2Error, createAria2Client } from './rpc.js';
|
|
3
|
+
/** Called only behind DSH's authenticated Connection route; never persists drafts. */
|
|
4
|
+
export async function testConnection(request, resolveSecret, lifetime) {
|
|
5
|
+
const respond = (value, status = 200) => Response.json(value, { status, headers: { 'cache-control': 'no-store' } });
|
|
6
|
+
try {
|
|
7
|
+
if (!request.headers.get('content-type')?.startsWith('application/json')) {
|
|
8
|
+
throw new Aria2Error('INVALID_INPUT', 'Expected application/json');
|
|
9
|
+
}
|
|
10
|
+
const text = await request.text();
|
|
11
|
+
if (Buffer.byteLength(text) > 8192)
|
|
12
|
+
throw new Aria2Error('INVALID_INPUT', 'Connection settings too large');
|
|
13
|
+
let input;
|
|
14
|
+
try {
|
|
15
|
+
input = JSON.parse(text);
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
throw new Aria2Error('INVALID_INPUT', 'Invalid JSON');
|
|
19
|
+
}
|
|
20
|
+
if (!input || typeof input !== 'object' || Array.isArray(input))
|
|
21
|
+
throw new Aria2Error('INVALID_INPUT', 'Expected connection settings');
|
|
22
|
+
const data = input;
|
|
23
|
+
if (Object.keys(data).some(key => !['rpcHost', 'rpcPort', 'rpcTimeoutMs', 'rpcSecretRef', 'secret'].includes(key))) {
|
|
24
|
+
throw new Aria2Error('INVALID_INPUT', 'Unknown connection setting');
|
|
25
|
+
}
|
|
26
|
+
if (typeof data.rpcSecretRef !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(data.rpcSecretRef)) {
|
|
27
|
+
throw new Aria2Error('INVALID_INPUT', 'Invalid credential reference');
|
|
28
|
+
}
|
|
29
|
+
if (data.secret !== undefined && (typeof data.secret !== 'string' || !data.secret || data.secret.length > 4096)) {
|
|
30
|
+
throw new Aria2Error('INVALID_INPUT', 'Invalid secret');
|
|
31
|
+
}
|
|
32
|
+
const config = { rpcHost: data.rpcHost, rpcPort: data.rpcPort, timeoutMs: data.rpcTimeoutMs, signal: AbortSignal.any([request.signal, lifetime]) };
|
|
33
|
+
// Validate host/port/timeouts before accessing a credential or sending anything.
|
|
34
|
+
createAria2Client(config);
|
|
35
|
+
const secret = data.secret ?? await resolveSecret(credentialRef(data.rpcSecretRef));
|
|
36
|
+
const result = await createAria2Client({ ...config, secret }).version();
|
|
37
|
+
return respond({ ok: true, ...result });
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
return respond({ ok: false, code: error instanceof Aria2Error ? error.code : 'CREDENTIAL_ERROR' }, error instanceof Aria2Error && error.code === 'INVALID_INPUT' ? 400 : 200);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
declare module '@deepseek-ai/dsh-llm' {
|
|
2
|
+
interface MessageSourceMap {
|
|
3
|
+
'aria2-rpc': {
|
|
4
|
+
kind: 'aria2-rpc';
|
|
5
|
+
};
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
export interface CompletionNotice {
|
|
9
|
+
gid: string;
|
|
10
|
+
failed: boolean;
|
|
11
|
+
}
|
|
12
|
+
/** Parse one aria2 WebSocket notification; ignore everything else without throwing. */
|
|
13
|
+
export declare function parseAria2Notification(data: unknown): CompletionNotice | undefined;
|
|
14
|
+
interface Socket {
|
|
15
|
+
close(): void;
|
|
16
|
+
send(data: string): void;
|
|
17
|
+
onopen: (() => void) | null;
|
|
18
|
+
onmessage: ((event: {
|
|
19
|
+
data: unknown;
|
|
20
|
+
}) => void) | null;
|
|
21
|
+
onclose: (() => void) | null;
|
|
22
|
+
onerror: (() => void) | null;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Keep one WebSocket open for aria2 completion/error notifications.
|
|
26
|
+
* ponytail: config changes need a plugin reload; reconnect uses the latest host/port.
|
|
27
|
+
*/
|
|
28
|
+
export declare function startAria2Events(options: {
|
|
29
|
+
endpoint: () => {
|
|
30
|
+
host: string;
|
|
31
|
+
port: number;
|
|
32
|
+
};
|
|
33
|
+
onNotice: (notice: CompletionNotice) => void;
|
|
34
|
+
signal: AbortSignal;
|
|
35
|
+
createSocket?: (url: string) => Socket;
|
|
36
|
+
}): void;
|
|
37
|
+
/** Build one concise, bounded notification after fetching best-effort task details. */
|
|
38
|
+
export declare function describeCompletion(notice: CompletionNotice, config: {
|
|
39
|
+
host: string;
|
|
40
|
+
port: number;
|
|
41
|
+
timeoutMs: number;
|
|
42
|
+
secret?: string;
|
|
43
|
+
signal: AbortSignal;
|
|
44
|
+
}): Promise<string>;
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { createAria2Client } from './rpc.js';
|
|
2
|
+
/** Parse one aria2 WebSocket notification; ignore everything else without throwing. */
|
|
3
|
+
export function parseAria2Notification(data) {
|
|
4
|
+
if (typeof data !== 'string' || data.length > 4096)
|
|
5
|
+
return undefined;
|
|
6
|
+
let payload;
|
|
7
|
+
try {
|
|
8
|
+
payload = JSON.parse(data);
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return undefined;
|
|
12
|
+
}
|
|
13
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload))
|
|
14
|
+
return undefined;
|
|
15
|
+
const p = payload;
|
|
16
|
+
if (p.jsonrpc !== '2.0' || (p.method !== 'aria2.onDownloadComplete' && p.method !== 'aria2.onDownloadError'))
|
|
17
|
+
return undefined;
|
|
18
|
+
if (!Array.isArray(p.params) || p.params.length !== 1)
|
|
19
|
+
return undefined;
|
|
20
|
+
const param = p.params[0];
|
|
21
|
+
if (!param || typeof param !== 'object' || Array.isArray(param))
|
|
22
|
+
return undefined;
|
|
23
|
+
const gid = param.gid;
|
|
24
|
+
if (typeof gid !== 'string' || !/^(?!0{16}$)[a-f0-9]{16}$/i.test(gid))
|
|
25
|
+
return undefined;
|
|
26
|
+
return { gid: gid.toLowerCase(), failed: p.method === 'aria2.onDownloadError' };
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Keep one WebSocket open for aria2 completion/error notifications.
|
|
30
|
+
* ponytail: config changes need a plugin reload; reconnect uses the latest host/port.
|
|
31
|
+
*/
|
|
32
|
+
export function startAria2Events(options) {
|
|
33
|
+
const connectSocket = options.createSocket ?? ((url) => new globalThis.WebSocket(url));
|
|
34
|
+
let socket;
|
|
35
|
+
let timer;
|
|
36
|
+
let stopped = false;
|
|
37
|
+
const schedule = () => {
|
|
38
|
+
if (stopped)
|
|
39
|
+
return;
|
|
40
|
+
timer = setTimeout(connect, 5000);
|
|
41
|
+
};
|
|
42
|
+
const connect = () => {
|
|
43
|
+
if (stopped || options.signal.aborted)
|
|
44
|
+
return;
|
|
45
|
+
try {
|
|
46
|
+
const { host, port } = options.endpoint();
|
|
47
|
+
const next = connectSocket(`ws://${host}:${port}/jsonrpc`);
|
|
48
|
+
socket = next;
|
|
49
|
+
// aria2 starts pushing notifications only after this subscription call.
|
|
50
|
+
next.onopen = () => {
|
|
51
|
+
try {
|
|
52
|
+
next.send(JSON.stringify({ jsonrpc: '2.0', id: 'aria2-rpc', method: 'system.listNotifications' }));
|
|
53
|
+
}
|
|
54
|
+
catch { /* close follows */ }
|
|
55
|
+
};
|
|
56
|
+
next.onmessage = (event) => {
|
|
57
|
+
const notice = parseAria2Notification(event.data);
|
|
58
|
+
if (notice)
|
|
59
|
+
options.onNotice(notice);
|
|
60
|
+
};
|
|
61
|
+
next.onclose = () => { if (!stopped)
|
|
62
|
+
schedule(); };
|
|
63
|
+
next.onerror = () => { try {
|
|
64
|
+
next.close();
|
|
65
|
+
}
|
|
66
|
+
catch { /* close follows */ } };
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
schedule();
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
const stop = () => {
|
|
73
|
+
stopped = true;
|
|
74
|
+
if (timer)
|
|
75
|
+
clearTimeout(timer);
|
|
76
|
+
try {
|
|
77
|
+
socket?.close();
|
|
78
|
+
}
|
|
79
|
+
catch { /* already gone */ }
|
|
80
|
+
};
|
|
81
|
+
options.signal.addEventListener('abort', stop, { once: true });
|
|
82
|
+
connect();
|
|
83
|
+
}
|
|
84
|
+
/** Build one concise, bounded notification after fetching best-effort task details. */
|
|
85
|
+
export async function describeCompletion(notice, config) {
|
|
86
|
+
const prefix = notice.failed ? '[aria2] 下载失败' : '[aria2] 下载完成';
|
|
87
|
+
let detail = '';
|
|
88
|
+
try {
|
|
89
|
+
const client = createAria2Client({ rpcHost: config.host, rpcPort: config.port, timeoutMs: config.timeoutMs, secret: config.secret, signal: config.signal });
|
|
90
|
+
const status = await client.status({ gid: notice.gid });
|
|
91
|
+
const file = status.files[0]?.path ?? status.directory;
|
|
92
|
+
detail = notice.failed
|
|
93
|
+
? `\n文件:${file}\n错误:${status.errorCode} ${status.error ?? ''}`.trimEnd()
|
|
94
|
+
: `\n文件:${file}\n大小:${status.totalBytes} 字节`;
|
|
95
|
+
}
|
|
96
|
+
catch { /* the event still matters without task details */ }
|
|
97
|
+
return `${prefix}:${notice.gid}${detail}`;
|
|
98
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
import z from '@deepseek-ai/schemastery';
|
|
3
|
+
export declare const name = "aria2-rpc";
|
|
4
|
+
export declare const inject: string[];
|
|
5
|
+
export declare const Config: z<Schemastery.ObjectS<NoInfer<{
|
|
6
|
+
rpcHost: z<string, string, "volatile-defined">;
|
|
7
|
+
rpcPort: z<number, number, "volatile-defined">;
|
|
8
|
+
rpcSecretRef: z<string, string, "volatile-defined">;
|
|
9
|
+
rpcTimeoutMs: z<number, number, "volatile-defined">;
|
|
10
|
+
}>>, Schemastery.ObjectT<NoInfer<{
|
|
11
|
+
rpcHost: z<string, string, "volatile-defined">;
|
|
12
|
+
rpcPort: z<number, number, "volatile-defined">;
|
|
13
|
+
rpcSecretRef: z<string, string, "volatile-defined">;
|
|
14
|
+
rpcTimeoutMs: z<number, number, "volatile-defined">;
|
|
15
|
+
}>>, "plain">;
|
|
16
|
+
type Configuration = ReturnType<typeof Config>;
|
|
17
|
+
export declare function apply(ctx: Context, config: Configuration): void;
|
|
18
|
+
export {};
|
package/lib/src/index.js
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import z from '@deepseek-ai/schemastery';
|
|
2
|
+
import { credentialRef } from '@deepseek-ai/dsh-credentials';
|
|
3
|
+
import { createUserMessage } from '@deepseek-ai/dsh-llm';
|
|
4
|
+
import { Aria2Error, createAria2Client } from './rpc.js';
|
|
5
|
+
import { testConnection } from './connection-test.js';
|
|
6
|
+
import { describeCompletion, startAria2Events } from './events.js';
|
|
7
|
+
export const name = 'aria2-rpc';
|
|
8
|
+
export const inject = ['tools', 'credentials', 'sandboxPolicy', 'agents'];
|
|
9
|
+
export const Config = z.object({
|
|
10
|
+
// Settings schemas cross to the browser: use serializable constraints, never Node callbacks.
|
|
11
|
+
rpcHost: z.string().pattern(/^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*\.?$/i).default('127.0.0.1').volatile(),
|
|
12
|
+
rpcPort: z.natural().min(1).max(65535).default(6800).volatile(),
|
|
13
|
+
rpcSecretRef: z.string().pattern(/^[A-Za-z_][A-Za-z0-9_]*$/).role('credential-ref').default('ARIA2_RPC_SECRET').volatile(),
|
|
14
|
+
rpcTimeoutMs: z.natural().min(1).max(60000).default(10000).volatile(),
|
|
15
|
+
});
|
|
16
|
+
const gidSchema = { type: 'string', pattern: '^[0-9a-fA-F]{16}$', description: '完整的 aria2 GID。作用于当前配置的 RPC 服务,可包含该服务已有的任务。' };
|
|
17
|
+
const schema = (properties, required) => ({ type: 'object', properties, required, additionalProperties: false });
|
|
18
|
+
export function apply(ctx, config) {
|
|
19
|
+
ctx.inject(['connection'], ready => {
|
|
20
|
+
const lifetime = new AbortController();
|
|
21
|
+
ready.effect(() => () => lifetime.abort());
|
|
22
|
+
// Connection owns browser authentication and Host/Origin checks for /api routes.
|
|
23
|
+
ready.connection.fetch.register({
|
|
24
|
+
path: '/api/aria2-rpc.test', methods: ['POST'], requestBody: 'buffered',
|
|
25
|
+
fetch: request => testConnection(request, async (ref) => (await ready.credentials.resolve(credentialRef(ref)))?.value, lifetime.signal),
|
|
26
|
+
});
|
|
27
|
+
});
|
|
28
|
+
ctx.inject(['settings'], ready => {
|
|
29
|
+
ready.effect(() => ready.settings.configure({ auto: false }, ctx.fiber));
|
|
30
|
+
});
|
|
31
|
+
ctx.effect(() => {
|
|
32
|
+
const lifetime = new AbortController();
|
|
33
|
+
const disposers = [];
|
|
34
|
+
const tracked = new Map();
|
|
35
|
+
const pending = new Map();
|
|
36
|
+
const logger = ctx.logger;
|
|
37
|
+
const notify = async (sessionId, notice) => {
|
|
38
|
+
const agent = ctx.agents.get(sessionId);
|
|
39
|
+
if (!agent)
|
|
40
|
+
return;
|
|
41
|
+
let secret;
|
|
42
|
+
try {
|
|
43
|
+
secret = (await ctx.credentials.resolve(credentialRef(config.rpcSecretRef.get())))?.value;
|
|
44
|
+
}
|
|
45
|
+
catch { /* no credential: still push */ }
|
|
46
|
+
const text = await describeCompletion(notice, {
|
|
47
|
+
host: config.rpcHost.get(), port: config.rpcPort.get(), timeoutMs: config.rpcTimeoutMs.get(), secret, signal: lifetime.signal,
|
|
48
|
+
});
|
|
49
|
+
agent.followup(createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'aria2-rpc' } }));
|
|
50
|
+
logger?.info?.(`[aria2-rpc] completion notification queued for ${sessionId}: ${notice.gid}`);
|
|
51
|
+
};
|
|
52
|
+
const handleNotice = (notice) => {
|
|
53
|
+
logger?.info?.(`[aria2-rpc] ${notice.failed ? 'error' : 'complete'} event: ${notice.gid}`);
|
|
54
|
+
const sessionId = tracked.get(notice.gid);
|
|
55
|
+
if (sessionId === undefined) {
|
|
56
|
+
pending.set(notice.gid, notice.failed);
|
|
57
|
+
if (pending.size > 50) {
|
|
58
|
+
const oldest = pending.keys().next().value;
|
|
59
|
+
if (oldest !== undefined)
|
|
60
|
+
pending.delete(oldest);
|
|
61
|
+
}
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
tracked.delete(notice.gid);
|
|
65
|
+
void notify(sessionId, notice).catch(() => { });
|
|
66
|
+
};
|
|
67
|
+
startAria2Events({
|
|
68
|
+
endpoint: () => ({ host: config.rpcHost.get(), port: config.rpcPort.get() }),
|
|
69
|
+
onNotice: handleNotice,
|
|
70
|
+
signal: lifetime.signal,
|
|
71
|
+
});
|
|
72
|
+
const register = (definition) => {
|
|
73
|
+
disposers.push(ctx.tools.register({
|
|
74
|
+
...definition,
|
|
75
|
+
async execute(args, exec) {
|
|
76
|
+
const result = await definition.execute(args, exec);
|
|
77
|
+
// DSH replaces cancelled successes; retain the queryable GID as an error instead.
|
|
78
|
+
if (exec.signal.aborted && result && typeof result === 'object' && 'outcome' in result && result.outcome === 'unknown_effect') {
|
|
79
|
+
throw new Error(JSON.stringify(result));
|
|
80
|
+
}
|
|
81
|
+
return result;
|
|
82
|
+
},
|
|
83
|
+
timeoutMs: 65000,
|
|
84
|
+
output: {
|
|
85
|
+
schema: { type: 'object', additionalProperties: true },
|
|
86
|
+
render: (_args, value) => [{ type: 'text', text: JSON.stringify(value) }],
|
|
87
|
+
},
|
|
88
|
+
presentCall: () => ({ card: 'generic', title: definition.name, kind: definition.name === 'aria2_status' || definition.name === 'aria2_list' ? 'read' : 'execute' }),
|
|
89
|
+
}));
|
|
90
|
+
};
|
|
91
|
+
const run = async (method, args, exec) => {
|
|
92
|
+
if (method !== 'status' && method !== 'list') {
|
|
93
|
+
const agent = exec.agent && ctx.agents.get(exec.agent.id);
|
|
94
|
+
const plan = agent?.ctx.get('planMode');
|
|
95
|
+
if (!agent || plan?.get(agent).active || plan?.get(agent).pending === true || ctx.sandboxPolicy.resolve({ session: agent.session }).mode !== 'danger-full-access') {
|
|
96
|
+
return { ok: false, error: { code: 'PERMISSION_DENIED', message: '创建和控制下载仅允许在退出计划模式后的完全访问模式执行;未发送 RPC。' } };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const rpcHost = config.rpcHost.get();
|
|
101
|
+
const rpcPort = config.rpcPort.get();
|
|
102
|
+
const timeoutMs = config.rpcTimeoutMs.get();
|
|
103
|
+
const ref = config.rpcSecretRef.get();
|
|
104
|
+
const secret = (await ctx.credentials.resolve(credentialRef(ref)))?.value;
|
|
105
|
+
const client = createAria2Client({ rpcHost, rpcPort, timeoutMs, secret, signal: lifetime.signal });
|
|
106
|
+
// Each boundary validates unknown input again; schemas are discovery, not enforcement.
|
|
107
|
+
const value = await client[method](args, { signal: exec.signal });
|
|
108
|
+
if (method === 'download' && exec.agent) {
|
|
109
|
+
const result = value;
|
|
110
|
+
if (result.outcome === 'ok') {
|
|
111
|
+
tracked.set(result.gid, exec.agent.id);
|
|
112
|
+
const failed = pending.get(result.gid);
|
|
113
|
+
if (failed !== undefined) {
|
|
114
|
+
pending.delete(result.gid);
|
|
115
|
+
void notify(exec.agent.id, { gid: result.gid, failed }).catch(() => { });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { ok: true, ...value };
|
|
120
|
+
}
|
|
121
|
+
catch (error) {
|
|
122
|
+
return { ok: false, error: { code: error instanceof Aria2Error ? error.code : 'CONFIG_OR_CREDENTIAL_ERROR', message: error instanceof Aria2Error ? error.message : '配置或凭据不可用,请检查插件设置。' } };
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
register({
|
|
126
|
+
name: 'aria2_download',
|
|
127
|
+
description: '向正在运行的 aria2 rpc 服务提交单文件下载链接,立刻返回 GID。不等待完成。文件保存在aria2 rpc所在设备。当下载源明确提供校验和时填写 checksum 参数。结果未知时先用 GID 查状态,不要重复提交。任务完成或失败时,插件会向发起会话推送通知。',
|
|
128
|
+
parameters: schema({
|
|
129
|
+
url: { type: 'string', description: 'HTTP/HTTPS 文件直链,不允许内嵌用户名和密码。' },
|
|
130
|
+
directory: { type: 'string', description: 'aria2 所在机器的绝对目录;省略使用其默认目录。' },
|
|
131
|
+
filename: { type: 'string', description: '纯文件名,不含目录;省略由 aria2 决定。禁止覆盖和自动改名。' },
|
|
132
|
+
checksum: { ...schema({
|
|
133
|
+
algorithm: { type: 'string', enum: ['sha-256', 'sha-1', 'md5'] },
|
|
134
|
+
digest: { type: 'string', description: '下载源提供的十六进制摘要:SHA-256 为 64 位,SHA-1 为 40 位,MD5 为 32 位。没有来源校验和时省略整个 checksum。' },
|
|
135
|
+
}, ['algorithm', 'digest']) },
|
|
136
|
+
proxy: { type: 'string', description: '省略继承 aria2 代理;空串直连;http://host:port 为本任务独立 HTTP 代理。不支持 SOCKS 或代理凭据。' },
|
|
137
|
+
}, ['url']),
|
|
138
|
+
execute: (args, exec) => run('download', args, exec),
|
|
139
|
+
});
|
|
140
|
+
register({
|
|
141
|
+
name: 'aria2_status',
|
|
142
|
+
description: '读取当前 RPC 服务中的一个任务状态、速度、文件路径和脱敏错误。不启动下载、不等待完成;complete 不单独证明曾执行哈希校验。',
|
|
143
|
+
parameters: schema({ gid: gidSchema }, ['gid']),
|
|
144
|
+
isConcurrencySafe: () => true,
|
|
145
|
+
execute: (args, exec) => run('status', args, exec),
|
|
146
|
+
});
|
|
147
|
+
register({
|
|
148
|
+
name: 'aria2_list',
|
|
149
|
+
description: '列出当前 RPC 服务中的任务:active、waiting、stopped 或全部。只读,不创建或修改任务。limit 限制每个分类的数量;offset 用于 waiting/stopped 分页,active 在本地按相同范围截取。',
|
|
150
|
+
parameters: schema({
|
|
151
|
+
state: { type: 'string', enum: ['active', 'waiting', 'stopped', 'all'], default: 'all', description: '要查看的任务分类;默认 all。' },
|
|
152
|
+
limit: { type: 'integer', minimum: 1, maximum: 100, default: 20, description: '每个分类最多返回多少条。' },
|
|
153
|
+
offset: { type: 'integer', minimum: 0, maximum: 1000000, default: 0, description: '跳过前面的任务;用于 waiting/stopped 分页。' },
|
|
154
|
+
}, []),
|
|
155
|
+
isConcurrencySafe: () => true,
|
|
156
|
+
execute: (args, exec) => run('list', args, exec),
|
|
157
|
+
});
|
|
158
|
+
register({
|
|
159
|
+
name: 'aria2_control',
|
|
160
|
+
description: '暂停、恢复或取消当前 RPC 服务中的任务。取消不会删除文件、不关闭 aria2;恢复仅适用于暂停任务。动作被接受后可查状态确认,结果未知时不盲目重试。',
|
|
161
|
+
parameters: schema({ gid: gidSchema, action: { type: 'string', enum: ['pause', 'resume', 'cancel'] } }, ['gid', 'action']),
|
|
162
|
+
execute: (args, exec) => run('control', args, exec),
|
|
163
|
+
});
|
|
164
|
+
return () => {
|
|
165
|
+
lifetime.abort();
|
|
166
|
+
tracked.clear();
|
|
167
|
+
pending.clear();
|
|
168
|
+
for (const dispose of disposers.reverse())
|
|
169
|
+
dispose();
|
|
170
|
+
};
|
|
171
|
+
});
|
|
172
|
+
}
|
package/lib/src/rpc.d.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
export type Aria2ErrorCode = 'INVALID_INPUT' | 'RPC_ERROR' | 'HTTP_ERROR' | 'REDIRECT' | 'BAD_RESPONSE' | 'RESPONSE_TOO_LARGE' | 'NETWORK' | 'TIMEOUT' | 'ABORTED';
|
|
2
|
+
/** Messages and enumerable fields are safe for Host display; raw remote errors are never attached. */
|
|
3
|
+
export declare class Aria2Error extends Error {
|
|
4
|
+
code: Aria2ErrorCode;
|
|
5
|
+
requestSent: boolean;
|
|
6
|
+
constructor(code: Aria2ErrorCode, message: string, requestSent?: boolean);
|
|
7
|
+
}
|
|
8
|
+
export interface Aria2ClientConfig {
|
|
9
|
+
rpcHost: string;
|
|
10
|
+
rpcPort: number;
|
|
11
|
+
secret?: string;
|
|
12
|
+
timeoutMs?: number;
|
|
13
|
+
signal?: AbortSignal;
|
|
14
|
+
}
|
|
15
|
+
export interface Aria2Checksum {
|
|
16
|
+
algorithm: 'sha-256' | 'sha-1' | 'md5';
|
|
17
|
+
digest: string;
|
|
18
|
+
}
|
|
19
|
+
export interface Aria2DownloadArgs {
|
|
20
|
+
url: string;
|
|
21
|
+
directory?: string;
|
|
22
|
+
filename?: string;
|
|
23
|
+
checksum?: Aria2Checksum;
|
|
24
|
+
/** undefined inherits aria2 config; '' is direct; otherwise an unauthenticated HTTP proxy. */
|
|
25
|
+
proxy?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface Aria2StatusArgs {
|
|
28
|
+
gid: string;
|
|
29
|
+
}
|
|
30
|
+
export type Aria2ControlAction = 'pause' | 'resume' | 'cancel';
|
|
31
|
+
export interface Aria2ControlArgs {
|
|
32
|
+
gid: string;
|
|
33
|
+
action: Aria2ControlAction;
|
|
34
|
+
}
|
|
35
|
+
export interface Aria2FileEntry {
|
|
36
|
+
path: string;
|
|
37
|
+
length: string;
|
|
38
|
+
completedLength: string;
|
|
39
|
+
}
|
|
40
|
+
export interface Aria2StatusResult {
|
|
41
|
+
gid: string;
|
|
42
|
+
status: 'active' | 'waiting' | 'paused' | 'error' | 'complete' | 'removed';
|
|
43
|
+
totalBytes: string;
|
|
44
|
+
completedBytes: string;
|
|
45
|
+
downloadSpeed: string;
|
|
46
|
+
uploadSpeed: string;
|
|
47
|
+
connections: string;
|
|
48
|
+
directory: string;
|
|
49
|
+
files: Aria2FileEntry[];
|
|
50
|
+
filesTruncated: boolean;
|
|
51
|
+
/** Fraction 0..1, rounded down to four decimal places; null for unknown total size. */
|
|
52
|
+
progress: number | null;
|
|
53
|
+
errorCode: string;
|
|
54
|
+
error: string | null;
|
|
55
|
+
}
|
|
56
|
+
export type Aria2ListState = 'active' | 'waiting' | 'stopped' | 'all';
|
|
57
|
+
export interface Aria2ListArgs {
|
|
58
|
+
state?: Aria2ListState;
|
|
59
|
+
limit?: number;
|
|
60
|
+
offset?: number;
|
|
61
|
+
}
|
|
62
|
+
export interface Aria2TaskSummary {
|
|
63
|
+
gid: string;
|
|
64
|
+
status: Aria2StatusResult['status'];
|
|
65
|
+
totalBytes: string;
|
|
66
|
+
completedBytes: string;
|
|
67
|
+
downloadSpeed: string;
|
|
68
|
+
connections: string;
|
|
69
|
+
directory: string;
|
|
70
|
+
file: string | null;
|
|
71
|
+
progress: number | null;
|
|
72
|
+
errorCode: string;
|
|
73
|
+
error: string | null;
|
|
74
|
+
}
|
|
75
|
+
export interface Aria2ListResult {
|
|
76
|
+
state: Aria2ListState;
|
|
77
|
+
offset: number;
|
|
78
|
+
limit: number;
|
|
79
|
+
active?: Aria2TaskSummary[];
|
|
80
|
+
waiting?: Aria2TaskSummary[];
|
|
81
|
+
stopped?: Aria2TaskSummary[];
|
|
82
|
+
}
|
|
83
|
+
export type Aria2MutateResult<T> = ({
|
|
84
|
+
outcome: 'ok';
|
|
85
|
+
} & T) | ({
|
|
86
|
+
outcome: 'unknown_effect';
|
|
87
|
+
code: 'UNKNOWN_EFFECT';
|
|
88
|
+
reason: string;
|
|
89
|
+
} & T);
|
|
90
|
+
export type Aria2DownloadResult = Aria2MutateResult<{
|
|
91
|
+
gid: string;
|
|
92
|
+
}>;
|
|
93
|
+
export type Aria2ControlResult = Aria2MutateResult<{
|
|
94
|
+
gid: string;
|
|
95
|
+
action: Aria2ControlAction;
|
|
96
|
+
}>;
|
|
97
|
+
export interface Aria2Client {
|
|
98
|
+
version(opts?: {
|
|
99
|
+
signal?: AbortSignal;
|
|
100
|
+
}): Promise<{
|
|
101
|
+
version: string;
|
|
102
|
+
}>;
|
|
103
|
+
download(args: Aria2DownloadArgs, opts?: {
|
|
104
|
+
signal?: AbortSignal;
|
|
105
|
+
}): Promise<Aria2DownloadResult>;
|
|
106
|
+
status(args: Aria2StatusArgs, opts?: {
|
|
107
|
+
signal?: AbortSignal;
|
|
108
|
+
}): Promise<Aria2StatusResult>;
|
|
109
|
+
list(args: Aria2ListArgs, opts?: {
|
|
110
|
+
signal?: AbortSignal;
|
|
111
|
+
}): Promise<Aria2ListResult>;
|
|
112
|
+
control(args: Aria2ControlArgs, opts?: {
|
|
113
|
+
signal?: AbortSignal;
|
|
114
|
+
}): Promise<Aria2ControlResult>;
|
|
115
|
+
}
|
|
116
|
+
export declare function validateHost(host: unknown): string;
|
|
117
|
+
export declare function createAria2Client(config: Aria2ClientConfig): Aria2Client;
|
package/lib/src/rpc.js
ADDED
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
import { randomBytes, randomUUID } from 'node:crypto';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
3
|
+
/** Messages and enumerable fields are safe for Host display; raw remote errors are never attached. */
|
|
4
|
+
export class Aria2Error extends Error {
|
|
5
|
+
code;
|
|
6
|
+
requestSent;
|
|
7
|
+
constructor(code, message, requestSent = false) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.name = 'Aria2Error';
|
|
10
|
+
this.code = code;
|
|
11
|
+
this.requestSent = requestSent;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
const MAX_RESPONSE_BYTES = 1024 * 1024;
|
|
15
|
+
const DNS_NAME = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*\.?$/i;
|
|
16
|
+
const GID = /^(?!0{16}$)[a-f0-9]{16}$/i;
|
|
17
|
+
const DECIMAL = /^[0-9]+$/;
|
|
18
|
+
const CONTROL_CHARS = /[\x00-\x1f\x7f]/;
|
|
19
|
+
const CHECKSUM_LENGTH = { 'sha-256': 64, 'sha-1': 40, md5: 32 };
|
|
20
|
+
const METHODS = { pause: 'aria2.pause', resume: 'aria2.unpause', cancel: 'aria2.remove' };
|
|
21
|
+
const STATUS_KEYS = ['gid', 'status', 'totalLength', 'completedLength', 'downloadSpeed',
|
|
22
|
+
'uploadSpeed', 'connections', 'dir', 'files', 'errorCode'];
|
|
23
|
+
const STATUSES = ['active', 'waiting', 'paused', 'error', 'complete', 'removed'];
|
|
24
|
+
function fail(code, message) { throw new Aria2Error(code, message); }
|
|
25
|
+
function object(value, label) {
|
|
26
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)
|
|
27
|
+
|| ![Object.prototype, null].includes(Object.getPrototypeOf(value))) {
|
|
28
|
+
fail('INVALID_INPUT', `${label}: expected a plain object`);
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
}
|
|
32
|
+
function fields(value, allowed, label) {
|
|
33
|
+
const result = object(value, label);
|
|
34
|
+
if (Reflect.ownKeys(result).some((key) => typeof key !== 'string' || !allowed.includes(key))) {
|
|
35
|
+
fail('INVALID_INPUT', `${label}: unknown field`);
|
|
36
|
+
}
|
|
37
|
+
return result;
|
|
38
|
+
}
|
|
39
|
+
function integer(value, label, max) {
|
|
40
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 1 || value > max) {
|
|
41
|
+
fail('INVALID_INPUT', `${label}: expected integer 1..${max}`);
|
|
42
|
+
}
|
|
43
|
+
return value;
|
|
44
|
+
}
|
|
45
|
+
function lifecycle(value) {
|
|
46
|
+
if (value !== undefined && !(value instanceof AbortSignal))
|
|
47
|
+
fail('INVALID_INPUT', 'signal: expected AbortSignal');
|
|
48
|
+
return value;
|
|
49
|
+
}
|
|
50
|
+
function gidValue(value) {
|
|
51
|
+
if (typeof value !== 'string' || !GID.test(value))
|
|
52
|
+
fail('INVALID_INPUT', 'gid: expected nonzero 16 hex characters');
|
|
53
|
+
return value.toLowerCase();
|
|
54
|
+
}
|
|
55
|
+
function urlValue(value, proxy = false) {
|
|
56
|
+
if (typeof value !== 'string' || !value || value.trim() !== value || CONTROL_CHARS.test(value)) {
|
|
57
|
+
fail('INVALID_INPUT', `${proxy ? 'proxy' : 'url'}: invalid URL`);
|
|
58
|
+
}
|
|
59
|
+
let parsed;
|
|
60
|
+
try {
|
|
61
|
+
parsed = new URL(value);
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
fail('INVALID_INPUT', `${proxy ? 'proxy' : 'url'}: invalid URL`);
|
|
65
|
+
}
|
|
66
|
+
if (!(proxy ? parsed.protocol === 'http:' : ['http:', 'https:'].includes(parsed.protocol))
|
|
67
|
+
|| !parsed.hostname || parsed.username || parsed.password
|
|
68
|
+
|| value.slice(value.indexOf('//') + 2).split(/[/?#]/, 1)[0]?.includes('@')
|
|
69
|
+
|| (proxy && (parsed.pathname !== '/' || parsed.search || parsed.hash))) {
|
|
70
|
+
fail('INVALID_INPUT', `${proxy ? 'proxy' : 'url'}: disallowed scheme, credentials or proxy path`);
|
|
71
|
+
}
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
function downloadOptions(args, gid) {
|
|
75
|
+
fields(args, ['url', 'directory', 'filename', 'checksum', 'proxy'], 'download args');
|
|
76
|
+
const options = { gid, 'allow-overwrite': 'false', 'auto-file-renaming': 'false' };
|
|
77
|
+
if (args.directory !== undefined) {
|
|
78
|
+
const dir = args.directory;
|
|
79
|
+
if (typeof dir !== 'string' || CONTROL_CHARS.test(dir)
|
|
80
|
+
|| !(dir.startsWith('/') || /^[a-z]:[\\/]/i.test(dir) || /^\\\\[^\\/]+[\\/][^\\/]+/.test(dir))) {
|
|
81
|
+
fail('INVALID_INPUT', 'directory: expected aria2-side absolute POSIX, drive or UNC path');
|
|
82
|
+
}
|
|
83
|
+
options.dir = dir;
|
|
84
|
+
}
|
|
85
|
+
if (args.filename !== undefined) {
|
|
86
|
+
const name = args.filename;
|
|
87
|
+
if (typeof name !== 'string' || !name || name === '.' || name === '..'
|
|
88
|
+
|| /[\\/:]/.test(name) || CONTROL_CHARS.test(name)) {
|
|
89
|
+
fail('INVALID_INPUT', 'filename: expected pure file name');
|
|
90
|
+
}
|
|
91
|
+
options.out = name;
|
|
92
|
+
}
|
|
93
|
+
if (args.checksum !== undefined) {
|
|
94
|
+
const checksum = fields(args.checksum, ['algorithm', 'digest'], 'checksum');
|
|
95
|
+
const algorithm = checksum.algorithm;
|
|
96
|
+
if (algorithm !== 'sha-256' && algorithm !== 'sha-1' && algorithm !== 'md5') {
|
|
97
|
+
fail('INVALID_INPUT', 'checksum.algorithm: expected sha-256, sha-1 or md5');
|
|
98
|
+
}
|
|
99
|
+
if (typeof checksum.digest !== 'string' || checksum.digest.length !== CHECKSUM_LENGTH[algorithm]
|
|
100
|
+
|| !/^[a-f0-9]+$/i.test(checksum.digest)) {
|
|
101
|
+
fail('INVALID_INPUT', 'checksum.digest: invalid hex length or characters');
|
|
102
|
+
}
|
|
103
|
+
options.checksum = `${algorithm}=${checksum.digest.toLowerCase()}`;
|
|
104
|
+
options['check-integrity'] = 'true';
|
|
105
|
+
}
|
|
106
|
+
if (args.proxy !== undefined) {
|
|
107
|
+
const proxy = args.proxy === '' ? '' : urlValue(args.proxy, true);
|
|
108
|
+
for (const key of ['all-proxy', 'http-proxy', 'https-proxy'])
|
|
109
|
+
options[key] = proxy;
|
|
110
|
+
for (const key of ['no-proxy', 'all-proxy-user', 'all-proxy-passwd', 'http-proxy-user',
|
|
111
|
+
'http-proxy-passwd', 'https-proxy-user', 'https-proxy-passwd'])
|
|
112
|
+
options[key] = '';
|
|
113
|
+
}
|
|
114
|
+
return options;
|
|
115
|
+
}
|
|
116
|
+
async function responseText(response) {
|
|
117
|
+
if (!response.body)
|
|
118
|
+
return '';
|
|
119
|
+
const reader = response.body.getReader();
|
|
120
|
+
const chunks = [];
|
|
121
|
+
let length = 0;
|
|
122
|
+
try {
|
|
123
|
+
for (;;) {
|
|
124
|
+
const { done, value } = await reader.read();
|
|
125
|
+
if (done)
|
|
126
|
+
break;
|
|
127
|
+
length += value.byteLength;
|
|
128
|
+
if (length > MAX_RESPONSE_BYTES) {
|
|
129
|
+
await reader.cancel().catch(() => { });
|
|
130
|
+
fail('RESPONSE_TOO_LARGE', 'aria2 response exceeds 1 MiB');
|
|
131
|
+
}
|
|
132
|
+
chunks.push(value);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
reader.releaseLock();
|
|
137
|
+
}
|
|
138
|
+
return Buffer.concat(chunks, length).toString('utf8');
|
|
139
|
+
}
|
|
140
|
+
function remoteString(value, decimal = false) {
|
|
141
|
+
if (typeof value !== 'string' || (decimal && !DECIMAL.test(value))) {
|
|
142
|
+
fail('BAD_RESPONSE', 'aria2 returned invalid status fields');
|
|
143
|
+
}
|
|
144
|
+
return value;
|
|
145
|
+
}
|
|
146
|
+
function statusResult(value, gid) {
|
|
147
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
148
|
+
fail('BAD_RESPONSE', 'aria2 status is not an object');
|
|
149
|
+
const r = value;
|
|
150
|
+
if (r.gid !== gid || !STATUSES.includes(r.status) || !Array.isArray(r.files)) {
|
|
151
|
+
fail('BAD_RESPONSE', 'aria2 returned invalid status fields');
|
|
152
|
+
}
|
|
153
|
+
const totalBytes = remoteString(r.totalLength, true);
|
|
154
|
+
const completedBytes = remoteString(r.completedLength, true);
|
|
155
|
+
const errorCode = r.errorCode === undefined ? '0' : remoteString(r.errorCode, true);
|
|
156
|
+
const total = BigInt(totalBytes);
|
|
157
|
+
const completed = BigInt(completedBytes);
|
|
158
|
+
const progress = total === 0n ? null : Number((completed > total ? total : completed) * 10000n / total) / 10000;
|
|
159
|
+
return {
|
|
160
|
+
gid, status: r.status, totalBytes, completedBytes,
|
|
161
|
+
downloadSpeed: remoteString(r.downloadSpeed, true), uploadSpeed: remoteString(r.uploadSpeed, true),
|
|
162
|
+
connections: remoteString(r.connections, true), directory: remoteString(r.dir),
|
|
163
|
+
files: r.files.slice(0, 20).map((entry) => {
|
|
164
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry))
|
|
165
|
+
fail('BAD_RESPONSE', 'aria2 returned invalid file fields');
|
|
166
|
+
const f = entry;
|
|
167
|
+
return { path: remoteString(f.path), length: remoteString(f.length, true),
|
|
168
|
+
completedLength: remoteString(f.completedLength, true) };
|
|
169
|
+
}),
|
|
170
|
+
filesTruncated: r.files.length > 20, progress, errorCode,
|
|
171
|
+
error: errorCode === '0' ? null : errorCode === '32' ? 'Checksum validation failed'
|
|
172
|
+
: errorCode === '3' ? 'Resource not found' : errorCode === '9' ? 'Insufficient disk space'
|
|
173
|
+
: errorCode === '13' ? 'File already exists' : 'Download failed',
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
function taskSummary(status) {
|
|
177
|
+
return {
|
|
178
|
+
gid: status.gid, status: status.status, totalBytes: status.totalBytes, completedBytes: status.completedBytes,
|
|
179
|
+
downloadSpeed: status.downloadSpeed, connections: status.connections, directory: status.directory,
|
|
180
|
+
file: status.files[0]?.path ?? null, progress: status.progress, errorCode: status.errorCode, error: status.error,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
function listItem(value) {
|
|
184
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
185
|
+
fail('BAD_RESPONSE', 'aria2 returned an invalid task entry');
|
|
186
|
+
const gid = value.gid;
|
|
187
|
+
return statusResult(value, gidValue(gid));
|
|
188
|
+
}
|
|
189
|
+
function listItems(value) {
|
|
190
|
+
if (!Array.isArray(value))
|
|
191
|
+
fail('BAD_RESPONSE', 'aria2 returned an invalid task list');
|
|
192
|
+
return value.map(listItem);
|
|
193
|
+
}
|
|
194
|
+
function offsetValue(value) {
|
|
195
|
+
if (value === undefined)
|
|
196
|
+
return 0;
|
|
197
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0 || value > 1_000_000) {
|
|
198
|
+
fail('INVALID_INPUT', 'offset: expected integer 0..1000000');
|
|
199
|
+
}
|
|
200
|
+
return value;
|
|
201
|
+
}
|
|
202
|
+
function mutationFailure(error, data) {
|
|
203
|
+
if (error instanceof Aria2Error && error.requestSent && error.code !== 'RPC_ERROR') {
|
|
204
|
+
return { ...data, outcome: 'unknown_effect', code: 'UNKNOWN_EFFECT', reason: error.code };
|
|
205
|
+
}
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
export function validateHost(host) {
|
|
209
|
+
if (typeof host !== 'string' || (!isIP(host) && (!DNS_NAME.test(host) || /^[0-9.]+$/.test(host))) || isIP(host) === 6) {
|
|
210
|
+
fail('INVALID_INPUT', 'rpcHost: expected DNS name or IPv4 (no scheme, port, path or IPv6)');
|
|
211
|
+
}
|
|
212
|
+
// URL parsing must not reinterpret DNS-looking input as a legacy numeric IPv4 address.
|
|
213
|
+
try {
|
|
214
|
+
if (new URL(`http://${host}/`).hostname !== host.toLowerCase())
|
|
215
|
+
throw new Error();
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
fail('INVALID_INPUT', 'rpcHost: unsupported DNS name or IPv4 spelling');
|
|
219
|
+
}
|
|
220
|
+
return host;
|
|
221
|
+
}
|
|
222
|
+
export function createAria2Client(config) {
|
|
223
|
+
fields(config, ['rpcHost', 'rpcPort', 'secret', 'timeoutMs', 'signal'], 'config');
|
|
224
|
+
const host = validateHost(config.rpcHost);
|
|
225
|
+
const port = integer(config.rpcPort, 'rpcPort', 65535);
|
|
226
|
+
const timeoutMs = config.timeoutMs === undefined ? 10000 : integer(config.timeoutMs, 'timeoutMs', 60000);
|
|
227
|
+
if (config.secret !== undefined && (typeof config.secret !== 'string' || !config.secret)) {
|
|
228
|
+
fail('INVALID_INPUT', 'secret: expected non-empty string');
|
|
229
|
+
}
|
|
230
|
+
const secret = config.secret;
|
|
231
|
+
const clientSignal = lifecycle(config.signal);
|
|
232
|
+
const endpoint = `http://${host}:${port}/jsonrpc`;
|
|
233
|
+
function signalFor(opts) {
|
|
234
|
+
if (opts === undefined)
|
|
235
|
+
return undefined;
|
|
236
|
+
return lifecycle(fields(opts, ['signal'], 'request options').signal);
|
|
237
|
+
}
|
|
238
|
+
async function rpc(method, params, callSignal) {
|
|
239
|
+
if (clientSignal?.aborted || callSignal?.aborted)
|
|
240
|
+
fail('ABORTED', 'aria2 request aborted before sending');
|
|
241
|
+
const id = randomUUID();
|
|
242
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
243
|
+
const signal = AbortSignal.any([timeout, ...[clientSignal, callSignal].filter((s) => !!s)]);
|
|
244
|
+
try {
|
|
245
|
+
const response = await fetch(endpoint, {
|
|
246
|
+
method: 'POST', headers: { 'content-type': 'application/json', accept: 'application/json' },
|
|
247
|
+
body: JSON.stringify({ jsonrpc: '2.0', id, method, params: secret === undefined ? params : [`token:${secret}`, ...params] }),
|
|
248
|
+
redirect: 'manual', signal,
|
|
249
|
+
});
|
|
250
|
+
if (response.status >= 300 && response.status < 400) {
|
|
251
|
+
void response.body?.cancel().catch(() => { });
|
|
252
|
+
fail('REDIRECT', 'aria2 endpoint redirect rejected');
|
|
253
|
+
}
|
|
254
|
+
if (response.status !== 200) {
|
|
255
|
+
void response.body?.cancel().catch(() => { });
|
|
256
|
+
fail('HTTP_ERROR', `aria2 endpoint returned HTTP ${response.status}`);
|
|
257
|
+
}
|
|
258
|
+
const text = await responseText(response);
|
|
259
|
+
let payload;
|
|
260
|
+
try {
|
|
261
|
+
payload = JSON.parse(text);
|
|
262
|
+
}
|
|
263
|
+
catch {
|
|
264
|
+
fail('BAD_RESPONSE', 'aria2 returned invalid JSON');
|
|
265
|
+
}
|
|
266
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload))
|
|
267
|
+
fail('BAD_RESPONSE', 'aria2 returned invalid JSON-RPC envelope');
|
|
268
|
+
const p = payload;
|
|
269
|
+
const hasResult = Object.hasOwn(p, 'result');
|
|
270
|
+
const hasError = Object.hasOwn(p, 'error');
|
|
271
|
+
if (p.jsonrpc !== '2.0' || p.id !== id || hasResult === hasError)
|
|
272
|
+
fail('BAD_RESPONSE', 'aria2 returned invalid JSON-RPC envelope or id');
|
|
273
|
+
if (hasError) {
|
|
274
|
+
if (!p.error || typeof p.error !== 'object' || Array.isArray(p.error)
|
|
275
|
+
|| !Number.isInteger(p.error.code)
|
|
276
|
+
|| typeof p.error.message !== 'string') {
|
|
277
|
+
fail('BAD_RESPONSE', 'aria2 returned malformed RPC error');
|
|
278
|
+
}
|
|
279
|
+
fail('RPC_ERROR', 'aria2 rejected the request (task may not exist or RPC authorization failed)');
|
|
280
|
+
}
|
|
281
|
+
return p.result;
|
|
282
|
+
}
|
|
283
|
+
catch (error) {
|
|
284
|
+
if (error instanceof Aria2Error) {
|
|
285
|
+
error.requestSent = true;
|
|
286
|
+
throw error;
|
|
287
|
+
}
|
|
288
|
+
const code = timeout.aborted ? 'TIMEOUT' : signal.aborted ? 'ABORTED' : 'NETWORK';
|
|
289
|
+
throw new Aria2Error(code, `aria2 request ${code.toLowerCase()}`, true);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
async version(opts) {
|
|
294
|
+
const result = await rpc('aria2.getVersion', [], signalFor(opts));
|
|
295
|
+
const version = result && typeof result === 'object' && !Array.isArray(result)
|
|
296
|
+
? result.version : undefined;
|
|
297
|
+
if (typeof version !== 'string' || !/^[0-9]+\.[0-9]+(?:\.[0-9]+)?(?:[-+][A-Za-z0-9.-]+)?$/.test(version) || version.length > 64) {
|
|
298
|
+
fail('BAD_RESPONSE', 'aria2 returned invalid version information');
|
|
299
|
+
}
|
|
300
|
+
return { version };
|
|
301
|
+
},
|
|
302
|
+
async download(args, opts) {
|
|
303
|
+
const gid = (() => { let value; do {
|
|
304
|
+
value = randomBytes(8).toString('hex');
|
|
305
|
+
} while (!GID.test(value)); return value; })();
|
|
306
|
+
const options = downloadOptions(args, gid);
|
|
307
|
+
const url = urlValue(args.url);
|
|
308
|
+
const signal = signalFor(opts);
|
|
309
|
+
try {
|
|
310
|
+
const result = await rpc('aria2.addUri', [[url], options], signal);
|
|
311
|
+
if (result !== gid)
|
|
312
|
+
throw new Aria2Error('BAD_RESPONSE', 'aria2 returned an unexpected download gid', true);
|
|
313
|
+
return { outcome: 'ok', gid };
|
|
314
|
+
}
|
|
315
|
+
catch (error) {
|
|
316
|
+
return mutationFailure(error, { gid });
|
|
317
|
+
}
|
|
318
|
+
},
|
|
319
|
+
async status(args, opts) {
|
|
320
|
+
const gid = gidValue(fields(args, ['gid'], 'status args').gid);
|
|
321
|
+
return statusResult(await rpc('aria2.tellStatus', [gid, STATUS_KEYS], signalFor(opts)), gid);
|
|
322
|
+
},
|
|
323
|
+
async list(args, opts) {
|
|
324
|
+
const a = fields(args, ['state', 'limit', 'offset'], 'list args');
|
|
325
|
+
const state = a.state === undefined ? 'all' : a.state;
|
|
326
|
+
if (state !== 'active' && state !== 'waiting' && state !== 'stopped' && state !== 'all') {
|
|
327
|
+
fail('INVALID_INPUT', 'state: expected active, waiting, stopped or all');
|
|
328
|
+
}
|
|
329
|
+
const limit = a.limit === undefined ? 20 : integer(a.limit, 'limit', 100);
|
|
330
|
+
const offset = offsetValue(a.offset);
|
|
331
|
+
const signal = signalFor(opts);
|
|
332
|
+
const result = { state, offset, limit };
|
|
333
|
+
if (state === 'active' || state === 'all') {
|
|
334
|
+
const active = listItems(await rpc('aria2.tellActive', [STATUS_KEYS], signal)).map(taskSummary);
|
|
335
|
+
result.active = active.slice(offset, offset + limit);
|
|
336
|
+
}
|
|
337
|
+
if (state === 'waiting' || state === 'all') {
|
|
338
|
+
result.waiting = listItems(await rpc('aria2.tellWaiting', [offset, limit, STATUS_KEYS], signal)).map(taskSummary);
|
|
339
|
+
}
|
|
340
|
+
if (state === 'stopped' || state === 'all') {
|
|
341
|
+
result.stopped = listItems(await rpc('aria2.tellStopped', [offset, limit, STATUS_KEYS], signal)).map(taskSummary);
|
|
342
|
+
}
|
|
343
|
+
return result;
|
|
344
|
+
},
|
|
345
|
+
async control(args, opts) {
|
|
346
|
+
const a = fields(args, ['gid', 'action'], 'control args');
|
|
347
|
+
const gid = gidValue(a.gid);
|
|
348
|
+
const action = a.action;
|
|
349
|
+
if (action !== 'pause' && action !== 'resume' && action !== 'cancel')
|
|
350
|
+
fail('INVALID_INPUT', 'action: expected pause, resume or cancel');
|
|
351
|
+
const signal = signalFor(opts);
|
|
352
|
+
try {
|
|
353
|
+
const result = await rpc(METHODS[action], [gid], signal);
|
|
354
|
+
if (result !== gid)
|
|
355
|
+
throw new Aria2Error('BAD_RESPONSE', 'aria2 returned an unexpected control gid', true);
|
|
356
|
+
return { outcome: 'ok', gid, action };
|
|
357
|
+
}
|
|
358
|
+
catch (error) {
|
|
359
|
+
return mutationFailure(error, { gid, action });
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
}
|
package/locale/en.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"meta":{"title":"Aria2 RPC","description":"Connect to an existing aria2 RPC service to create, inspect, pause, resume or cancel downloads. No bundled aria2."}}
|
package/locale/zh.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"meta":{"title":"Aria2 RPC","description":"连接已有 aria2 RPC 服务,创建、查看、暂停、恢复或取消下载;不附带 aria2。"}}
|
package/package.json
CHANGED
|
@@ -1,6 +1,68 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rosmeowtis/dsh-aria2-rpc",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Control an existing aria2 JSON-RPC service from DSH. No bundled downloader.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/Rosmeowtis/dsh-aria2-rpc.git"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "./lib/src/index.js",
|
|
12
|
+
"types": "./lib/src/index.d.ts",
|
|
13
|
+
"icon": "./icon.svg",
|
|
14
|
+
"exports": {
|
|
15
|
+
".": {
|
|
16
|
+
"types": "./lib/src/index.d.ts",
|
|
17
|
+
"import": "./lib/src/index.js"
|
|
18
|
+
},
|
|
19
|
+
"./client": "./client.js",
|
|
20
|
+
"./package.json": "./package.json",
|
|
21
|
+
"./locale/*.json": "./locale/*.json"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"lib/src",
|
|
25
|
+
"client.js",
|
|
26
|
+
"locale/*.json",
|
|
27
|
+
"icon.svg",
|
|
28
|
+
"cordis.patch.yml",
|
|
29
|
+
"README.md",
|
|
30
|
+
"LICENSE"
|
|
31
|
+
],
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=22.18",
|
|
34
|
+
"dsh": "^0.2.1-rc.5"
|
|
35
|
+
},
|
|
36
|
+
"dsh": {
|
|
37
|
+
"bundle": {
|
|
38
|
+
"patch": "./cordis.patch.yml"
|
|
39
|
+
},
|
|
40
|
+
"client": {
|
|
41
|
+
"platform": "web",
|
|
42
|
+
"inject": [
|
|
43
|
+
"@deepseek-ai/dsh-client-ui-plugin-manager",
|
|
44
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"build": "tsc",
|
|
50
|
+
"test": "npm run build && node --check client.js && node --test lib/test/*.test.js",
|
|
51
|
+
"typecheck": "tsc --noEmit",
|
|
52
|
+
"prepack": "npm run build"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
56
|
+
"@deepseek-ai/schemastery": "^3.18.2",
|
|
57
|
+
"@deepseek-ai/dsh-agent": "0.2.1-rc.5",
|
|
58
|
+
"@deepseek-ai/dsh-credentials": "0.2.1-rc.5",
|
|
59
|
+
"@deepseek-ai/dsh-client-connection": "0.2.1-rc.5",
|
|
60
|
+
"@deepseek-ai/dsh-llm": "0.2.1-rc.5",
|
|
61
|
+
"@deepseek-ai/dsh-plan-mode": "0.2.1-rc.5",
|
|
62
|
+
"@deepseek-ai/dsh-sandbox-policy": "0.2.1-rc.5",
|
|
63
|
+
"@deepseek-ai/dsh-settings": "0.2.1-rc.5",
|
|
64
|
+
"@deepseek-ai/dsh-tools": "0.2.1-rc.5",
|
|
65
|
+
"@types/node": "^22.0.0",
|
|
66
|
+
"typescript": "~5.9.3"
|
|
67
|
+
}
|
|
68
|
+
}
|