dsh-zspace 0.1.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.md +29 -4
- package/cordis.patch.yml +16 -2
- package/lib/README.md +6 -3
- package/lib/client/browse.js +0 -7
- package/lib/client/download.js +0 -15
- package/lib/client/mutate.js +0 -31
- package/lib/client/relay.js +30 -0
- package/lib/client/router.js +132 -0
- package/lib/client/session.js +0 -7
- package/lib/client/spaces.js +2 -32
- package/lib/client/transport.js +0 -50
- package/lib/client/upload.js +0 -12
- package/lib/client/webdav.js +622 -0
- package/lib/client.js +55 -28
- package/lib/config.js +38 -0
- package/lib/index.js +7 -0
- package/lib/tools/ls.js +1 -1
- package/lib/tools/status.js +21 -5
- package/lib/tools/write.js +73 -0
- package/lib/tools.js +2 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
# 变更日志
|
|
2
2
|
|
|
3
|
+
## 0.3.0 — 2026-10-07
|
|
4
|
+
|
|
5
|
+
**双通道**:同一网络时直连 NAS 的 WebDAV,跨网络继续走桌面客户端云中转,插件自动选路。工具名与参数**完全不变**(仍是 13 个),既有配置继续可用。
|
|
6
|
+
|
|
7
|
+
- 新增 `lib/client/webdav.js`:WebDAV 传输层(PROPFIND 列目录/取元信息、MKCOL、PUT、GET(含 Range)、MOVE、COPY、DELETE、Basic 鉴权、namespace 无关的 multistatus 解析、NAS 路径 ↔ DAV 路径双向映射)。
|
|
8
|
+
- 新增 `lib/client/router.js`:选路与回退。`auto`(默认)按**可达性**探测(1.5s PROPFIND,结果缓存 60s);**运行中** WebDAV 失败(超时/拒绝连接/5xx)会标记下线 30s 并在**同一次调用内**改用中转重试,业务错误(401/403/404/405/409、`N00…`)不触发回退。
|
|
9
|
+
- 新增配置:`transportMode`(auto/webdav/relay)、`webdavUrl`、`webdavUser`、`webdavPassword`、`webdavHomePath`、`webdavPublicPath`、`webdavProbeTimeoutMs`。**密码建议只放环境变量 `ZS_WEBDAV_PASSWORD`(可选 `ZS_WEBDAV_USER`),不要写进 patch**。
|
|
10
|
+
- `zspace_status` 增加 `transport` / `webdavUrl` / `webdavProbe` 字段,并说明当前在用哪条通道、不可用时两边的原因。
|
|
11
|
+
- WebDAV 通道的收益:直连低延迟、不依赖桌面客户端在线、目录列表一次 `PROPFIND` 拿全(没有中转 50 行分页)。注意 `zspace_remove` 在直连通道下是 NAS 侧删除语义(是否进回收站由 NAS 决定)。
|
|
12
|
+
- 验证:**36 项测试**(新增 `test/webdav.test.js` 7 项,含真 socket 的 mock WebDAV 服务器、鉴权失败/不可达探测、auto 选路、运行期回退、强制模式不回退);真机自检新增通道诊断步骤(配了密码时还会跑一遍 WebDAV 直连写→读往返)。
|
|
13
|
+
|
|
14
|
+
## 0.1.2 — 2026-10-06
|
|
15
|
+
|
|
16
|
+
新增一个写入类工具 + 一个配置项,并把文档里的真实池名换成占位符(**无破坏性变更**,工具表新增一行):
|
|
17
|
+
|
|
18
|
+
- 新增 `zspace_write`:把文本直接写到 NAS,省掉"先生成本地临时文件再上传"这一步。默认覆盖同名文件,`overwrite: false` 时先探测存在性并拒绝;体积受 `writeMaxBytes` 约束;底层复用同一条上传路径(自动决定单请求/分片),临时文件无论成败都会清理。
|
|
19
|
+
- 新增配置项 `writeMaxBytes`(默认 5 MB),四处同步:Config schema、DEFAULTS、加载期校验、`cordis.patch.yml`。
|
|
20
|
+
- 文档与工具描述里的示例路径由真实的 `/<具体池>/...` 泛化为 `/<pool>/...`(探测候选路径代码保留,那是发现算法的一部分)。
|
|
21
|
+
- 清掉 0.1.1 拆分脚本引入的 19 处重复 JSDoc。
|
|
22
|
+
- 验证:29 项 mock 测试(新增 zspace_write 的 4 类断言)+ 真机 21 步自检(新增"直写文本→读回内容比对")。
|
|
23
|
+
|
|
3
24
|
## 0.1.1 — 2026-10-06
|
|
4
25
|
|
|
5
26
|
纯内部重构,**对外行为与 API 不变**(`ZSpaceClient` / `ZSpaceError` / `createToolSpecs` / 12 个工具名与参数完全保持):
|
package/README.md
CHANGED
|
@@ -30,17 +30,35 @@ dsh plugin --profile desktop add /绝对路径/dsh-zspace-0.1.0.tgz
|
|
|
30
30
|
2. 该账号对目标空间有权限(个人空间 / 公共空间);
|
|
31
31
|
3. 不需要给 NAS 开任何端口,也不需要 DDNS。
|
|
32
32
|
|
|
33
|
+
## 双通道:同网络直连 WebDAV,跨网络走云中转
|
|
34
|
+
|
|
35
|
+
插件对同一套工具**自动选路**,判断依据是**可达性**而不是 IP 网段(本机在 `192.168.31.x` 也能直连 `<nas-ip>` 的 NAS):
|
|
36
|
+
|
|
37
|
+
| 情况 | 通道 | 条件 |
|
|
38
|
+
|---|---|---|
|
|
39
|
+
| 能直连 NAS(同网络) | **WebDAV 直连** | 配了 `webdavUrl` + 环境变量 `ZS_WEBDAV_PASSWORD`;1.5s PROPFIND 探测通过即启用 |
|
|
40
|
+
| 跨网络 / WebDAV 不可达 | 桌面客户端云中转(默认) | 桌面客户端已登录并在运行 |
|
|
41
|
+
|
|
42
|
+
- WebDAV 通道的好处:延迟更低、不依赖桌面客户端在线、目录列表一次 `PROPFIND` 拿全(没有中转的 50 行分页)。
|
|
43
|
+
- 运行中 WebDAV 掉了(换网、NAS 重启)会**自动回退到中转并在同一次调用内重试**,只损失一点延迟,不会让工具调用失败;30s 后重新探测。
|
|
44
|
+
- `transportMode`:`auto`(默认)/ `webdav`(强制,失败直接报错)/ `relay`(保持旧行为)。
|
|
45
|
+
- 路径映射:NAS 的 `/sata1/my/data/...` ↔ WebDAV 的 `<webdavHomePath>/...`(默认根 `/`);公共空间由 `webdavPublicPath` 指定(默认空=不在 WebDAV 暴露,相关路径自动回到中转)。
|
|
46
|
+
- 极空间要在「系统设置 → 文件服务」里开启 WebDAV(默认端口 `5005`,本机实测可用;`5006` 是自签 HTTPS,证书校验会失败)。
|
|
47
|
+
- 密码只走环境变量(`ZS_WEBDAV_PASSWORD`,可选 `ZS_WEBDAV_USER`),**不要写进 patch 文件**——patch 是明文,会被备份/截图带走。
|
|
48
|
+
- `zspace_remove` 在 WebDAV 通道下是 NAS 侧的删除语义(是否进回收站由 NAS 决定);要确保进回收站请用 `transportMode: relay`。
|
|
49
|
+
|
|
33
50
|
## 工具
|
|
34
51
|
|
|
35
52
|
| 工具 | 作用 |
|
|
36
53
|
|---|---|
|
|
37
|
-
| `zspace_status` |
|
|
54
|
+
| `zspace_status` | 探活:**当前通道**(WebDAV 直连 / 云中转)、代理、账号、NAS、存储池余量、个人/公共空间根路径与条目数。其它工具报错时先跑它 |
|
|
38
55
|
| `zspace_ls` | 列目录,支持 `depth` 递归与 `limit` 预算;自动分页(NAS 单页 50 条) |
|
|
39
56
|
| `zspace_stat` | 单个文件/目录的元信息(大小、修改/创建时间) |
|
|
40
57
|
| `zspace_find` | 按名称在目录树里查找(大小写不敏感子串),会报告扫描了多少条目、是否提前停止 |
|
|
41
58
|
| `zspace_read` | 把小文本文件读进上下文(默认上限 256 KB,二进制自动识别) |
|
|
42
59
|
| `zspace_download` | 从 NAS 下载到本机(默认落到 `~/Downloads/zspace`) |
|
|
43
60
|
| `zspace_upload` | 上传本地文件,**大文件自动走分片协议**,中文文件名可用 |
|
|
61
|
+
| `zspace_write` | 把文本**直接写到 NAS**(省掉本地临时文件),默认覆盖、可传 `overwrite: false` 拒绝覆盖 |
|
|
44
62
|
| `zspace_mkdir` | 建目录 |
|
|
45
63
|
| `zspace_rename` | 就地重命名 |
|
|
46
64
|
| `zspace_move` | 移动到另一个目录(服务端操作,不下载) |
|
|
@@ -51,11 +69,11 @@ dsh plugin --profile desktop add /绝对路径/dsh-zspace-0.1.0.tgz
|
|
|
51
69
|
|
|
52
70
|
| 写法 | 含义 |
|
|
53
71
|
|---|---|
|
|
54
|
-
| 省略 / `""` |
|
|
72
|
+
| 省略 / `""` | 个人空间根(形如 `/<pool>/my/data`,实际值由插件探测) |
|
|
55
73
|
| `home:相册` | 个人空间下的 `相册` |
|
|
56
|
-
| `public:公共分组/skills` |
|
|
74
|
+
| `public:公共分组/skills` | 公共空间下的路径(形如 `/<pool>/public/...`) |
|
|
57
75
|
| `docs/2026` | 相对个人空间根 |
|
|
58
|
-
|
|
|
76
|
+
| `/<pool>/my/data/x.txt` | NAS 绝对路径,原样使用 |
|
|
59
77
|
|
|
60
78
|
两个根路径由插件按存储池自动探测(`/<pool>/my/data`、`/<pool>/public`),也可在配置里写死。
|
|
61
79
|
|
|
@@ -74,7 +92,14 @@ dsh plugin --profile desktop add /绝对路径/dsh-zspace-0.1.0.tgz
|
|
|
74
92
|
| `apiVersion` | `2.3.2026042401` | NAS Web API 版本号参数 |
|
|
75
93
|
| `homePath` / `publicPath` | 空=自动探测 | 写死个人/公共空间根 |
|
|
76
94
|
| `downloadDir` | 空=`~/Downloads/zspace` | 默认下载目录 |
|
|
95
|
+
| `transportMode` | `auto` | 通道:`auto` / `webdav` / `relay` |
|
|
96
|
+
| `webdavUrl` | 空=关闭 | WebDAV 地址,如 `http://<nas-ip>:5005/` |
|
|
97
|
+
| `webdavUser` | 空 | WebDAV 账号(NAS 账号);密码走 `ZS_WEBDAV_PASSWORD` |
|
|
98
|
+
| `webdavPassword` | 空 | 密码兜底项,建议留空改用环境变量 |
|
|
99
|
+
| `webdavHomePath` / `webdavPublicPath` | `/` / 空 | 个人/公共空间在 WebDAV 里的路径(空=公共空间不暴露) |
|
|
100
|
+
| `webdavProbeTimeoutMs` | `1500` | 直连探测超时 |
|
|
77
101
|
| `readMaxBytes` | `262144` | `zspace_read` 上限 |
|
|
102
|
+
| `writeMaxBytes` | `5242880` | `zspace_write` 单次写入上限(更大的内容用 `zspace_upload`) |
|
|
78
103
|
| `listMaxEntries` | `2000` | 单次列目录/树的上限 |
|
|
79
104
|
| `timeoutMs` / `maxRetries` | `60000` / `2` | 请求超时与瞬时失败重试 |
|
|
80
105
|
| `smallUploadMaxBytes` | `8388608` | 超过就用分片上传 |
|
package/cordis.patch.yml
CHANGED
|
@@ -3,11 +3,17 @@
|
|
|
3
3
|
# A patch replaces the targeted row's whole `config`, so a profile that
|
|
4
4
|
# overrides this row must restate every key it wants to keep.
|
|
5
5
|
#
|
|
6
|
+
# WebDAV fast path: when the machine can reach the NAS directly (same network),
|
|
7
|
+
# `transportMode: auto` probes this URL and uses it; otherwise every call falls
|
|
8
|
+
# back to the desktop client relay. The password is NOT stored here — set
|
|
9
|
+
# ZS_WEBDAV_PASSWORD in the host environment (and optionally ZS_WEBDAV_USER).
|
|
10
|
+
#
|
|
6
11
|
# Every value below has a code-side default, so this patch stays valid even on
|
|
7
12
|
# a host without patch-side helper evaluation. `homePath` / `publicPath` are
|
|
8
13
|
# intentionally empty: the plugin discovers them from the NAS storage pools at
|
|
9
|
-
# runtime (
|
|
10
|
-
# working on NAS models that name
|
|
14
|
+
# runtime (personal space looks like /<pool>/my/data, public space like
|
|
15
|
+
# /<pool>/public), which keeps the same file working on NAS models that name
|
|
16
|
+
# their pools differently.
|
|
11
17
|
|
|
12
18
|
- insert:
|
|
13
19
|
- id: zspace
|
|
@@ -19,10 +25,18 @@
|
|
|
19
25
|
publicPath: ""
|
|
20
26
|
downloadDir: ""
|
|
21
27
|
readMaxBytes: 262144
|
|
28
|
+
writeMaxBytes: 5242880
|
|
22
29
|
listMaxEntries: 2000
|
|
23
30
|
timeoutMs: 60000
|
|
24
31
|
maxRetries: 2
|
|
25
32
|
smallUploadMaxBytes: 8388608
|
|
26
33
|
sliceSize: 2097152
|
|
34
|
+
transportMode: auto
|
|
35
|
+
webdavUrl: ""
|
|
36
|
+
webdavUser: ""
|
|
37
|
+
webdavPassword: ""
|
|
38
|
+
webdavHomePath: /
|
|
39
|
+
webdavPublicPath: ""
|
|
40
|
+
webdavProbeTimeoutMs: 1500
|
|
27
41
|
promptEnabled: true
|
|
28
42
|
promptOrder: 60
|
package/lib/README.md
CHANGED
|
@@ -17,15 +17,18 @@ lib/
|
|
|
17
17
|
│ ├── spaces.js 153 行 代理探活、存储池、个人/公共空间根探测
|
|
18
18
|
│ ├── browse.js 64 行 分页列目录、单条元信息
|
|
19
19
|
│ ├── mutate.js 102 行 mkdir / rename / move / copy / remove
|
|
20
|
+
│ ├── relay.js 31 行 中转通道聚合(把下面各能力汇总成一个面)
|
|
21
|
+
│ ├── webdav.js 520 行 WebDAV 直连通道(PROPFIND/PUT/MOVE/COPY + 路径双向映射)
|
|
22
|
+
│ ├── router.js 99 行 选路:auto 探测 / 强制 / 运行期回退
|
|
20
23
|
│ ├── upload.js 129 行 单请求上传 + 分片协议
|
|
21
24
|
│ └── download.js 128 行 流式下载、限量读入内存
|
|
22
|
-
├── tools.js
|
|
25
|
+
├── tools.js 68 行 工具注册表:建 ctx、按顺序调用 13 个工厂
|
|
23
26
|
└── tools/
|
|
24
27
|
├── shared.js 52 行 clampInt / entryLine / guarded
|
|
25
28
|
├── paths.js 84 行 home:/public:/相对/绝对 解析 + 本地路径(含 ~/)
|
|
26
29
|
├── walk.js 118 行 walkDirectory(ls 递归)、findByName(受限查找)
|
|
27
|
-
└── <工具名>.js 35-
|
|
28
|
-
download upload mkdir rename move copy remove
|
|
30
|
+
└── <工具名>.js 35-120 行 每个工具一个文件:status ls stat find read
|
|
31
|
+
download upload write mkdir rename move copy remove
|
|
29
32
|
```
|
|
30
33
|
|
|
31
34
|
## 依赖方向(严格单向,无环)
|
package/lib/client/browse.js
CHANGED
|
@@ -39,13 +39,6 @@ export async function list(client, remotePath, options = {}) {
|
|
|
39
39
|
return { entries, truncated };
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
-
/**
|
|
43
|
-
* File or directory metadata.
|
|
44
|
-
*
|
|
45
|
-
* @param {string} remotePath - absolute remote path.
|
|
46
|
-
* @returns {Promise<ReturnType<typeof toEntry>>} normalized entry.
|
|
47
|
-
*/
|
|
48
|
-
|
|
49
42
|
/**
|
|
50
43
|
* File or directory metadata.
|
|
51
44
|
*
|
package/lib/client/download.js
CHANGED
|
@@ -43,14 +43,6 @@ export async function download(client, remotePath, localDir, options = {}) {
|
|
|
43
43
|
return { localPath: dest, bytes: stat.size };
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
-
/**
|
|
47
|
-
* Read the head of a remote file into memory.
|
|
48
|
-
*
|
|
49
|
-
* @param {string} remotePath - absolute remote file.
|
|
50
|
-
* @param {{maxBytes?: number}} [options] - byte cap.
|
|
51
|
-
* @returns {Promise<{buffer: Buffer, truncated: boolean, bytes: number}>} content.
|
|
52
|
-
*/
|
|
53
|
-
|
|
54
46
|
/**
|
|
55
47
|
* Read the head of a remote file into memory.
|
|
56
48
|
*
|
|
@@ -72,13 +64,6 @@ export async function readFile(client, remotePath, options = {}) {
|
|
|
72
64
|
return { buffer, truncated, bytes: buffer.length };
|
|
73
65
|
}
|
|
74
66
|
|
|
75
|
-
/**
|
|
76
|
-
* Open a download response, retrying transient proxy failures.
|
|
77
|
-
*
|
|
78
|
-
* @param {string} remotePath - absolute remote file.
|
|
79
|
-
* @returns {Promise<import("node:http").IncomingMessage>} the open response (caller consumes it).
|
|
80
|
-
*/
|
|
81
|
-
|
|
82
67
|
/**
|
|
83
68
|
* Open a download response, retrying transient proxy failures.
|
|
84
69
|
*
|
package/lib/client/mutate.js
CHANGED
|
@@ -18,14 +18,6 @@ export async function mkdir(client, remotePath) {
|
|
|
18
18
|
return toEntry(data ?? {});
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
/**
|
|
22
|
-
* Rename a file or directory in place.
|
|
23
|
-
*
|
|
24
|
-
* @param {string} remotePath - absolute current path.
|
|
25
|
-
* @param {string} newName - new base name (no directory part).
|
|
26
|
-
* @returns {Promise<ReturnType<typeof toEntry>>} the renamed entry.
|
|
27
|
-
*/
|
|
28
|
-
|
|
29
21
|
/**
|
|
30
22
|
* Rename a file or directory in place.
|
|
31
23
|
*
|
|
@@ -38,14 +30,6 @@ export async function rename(client, remotePath, newName) {
|
|
|
38
30
|
return toEntry(data ?? {});
|
|
39
31
|
}
|
|
40
32
|
|
|
41
|
-
/**
|
|
42
|
-
* Move files or directories into a target directory.
|
|
43
|
-
*
|
|
44
|
-
* @param {string[]} remotePaths - absolute source paths.
|
|
45
|
-
* @param {string} to - absolute destination directory.
|
|
46
|
-
* @returns {Promise<void>} resolves when the NAS accepted the move.
|
|
47
|
-
*/
|
|
48
|
-
|
|
49
33
|
/**
|
|
50
34
|
* Move files or directories into a target directory.
|
|
51
35
|
*
|
|
@@ -57,14 +41,6 @@ export async function move(client, remotePaths, to) {
|
|
|
57
41
|
await client.post("/v2/file/move", { paths: remotePaths, to });
|
|
58
42
|
}
|
|
59
43
|
|
|
60
|
-
/**
|
|
61
|
-
* Copy files or directories into a target directory.
|
|
62
|
-
*
|
|
63
|
-
* @param {string[]} remotePaths - absolute source paths.
|
|
64
|
-
* @param {string} to - absolute destination directory.
|
|
65
|
-
* @returns {Promise<void>} resolves when the NAS accepted the copy.
|
|
66
|
-
*/
|
|
67
|
-
|
|
68
44
|
/**
|
|
69
45
|
* Copy files or directories into a target directory.
|
|
70
46
|
*
|
|
@@ -76,13 +52,6 @@ export async function copy(client, remotePaths, to) {
|
|
|
76
52
|
await client.post("/v2/file/copy", { paths: remotePaths, to });
|
|
77
53
|
}
|
|
78
54
|
|
|
79
|
-
/**
|
|
80
|
-
* Delete files or directories (the NAS moves them to its recycle bin).
|
|
81
|
-
*
|
|
82
|
-
* @param {string[]} remotePaths - absolute paths to remove.
|
|
83
|
-
* @returns {Promise<void>} resolves when the NAS accepted the deletion.
|
|
84
|
-
*/
|
|
85
|
-
|
|
86
55
|
/**
|
|
87
56
|
* Delete files or directories (the NAS moves them to its recycle bin).
|
|
88
57
|
*
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relay transport: everything through the desktop client's local proxy.
|
|
3
|
+
*
|
|
4
|
+
* This is the original (cross-network) path, kept as one uniform surface so the
|
|
5
|
+
* router can treat it and the WebDAV transport interchangeably. Each function
|
|
6
|
+
* here is a straight re-export of the capability module that implements it.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-zspace/client/relay
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { check, homePath, isDirectory, pools, publicPath } from "./spaces.js";
|
|
12
|
+
export { info, list } from "./browse.js";
|
|
13
|
+
export { copy, mkdir, move, remove, rename } from "./mutate.js";
|
|
14
|
+
export { upload, uploadSliced } from "./upload.js";
|
|
15
|
+
export { download, openDownload, readFile } from "./download.js";
|
|
16
|
+
|
|
17
|
+
/** Transport name used by the router and status output. */
|
|
18
|
+
export const name = "relay";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* How to probe this transport. The relay is "up" when the desktop client's
|
|
22
|
+
* local proxy answers — the same check the plugin has always used.
|
|
23
|
+
*
|
|
24
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
25
|
+
* @returns {Promise<boolean>} whether the proxy answers.
|
|
26
|
+
*/
|
|
27
|
+
export async function probe(client) {
|
|
28
|
+
const { check } = await import("./spaces.js");
|
|
29
|
+
return await check(client);
|
|
30
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport router — WebDAV fast path vs. desktop-client relay.
|
|
3
|
+
*
|
|
4
|
+
* The plugin always speaks the same tool surface; this module decides which
|
|
5
|
+
* transport serves a call:
|
|
6
|
+
*
|
|
7
|
+
* | mode | behaviour |
|
|
8
|
+
* |---|---|
|
|
9
|
+
* | `auto` (default) | probe WebDAV once per minute; usable → WebDAV, otherwise relay |
|
|
10
|
+
* | `webdav` | always WebDAV (failures surface loudly) |
|
|
11
|
+
* | `relay` | always the desktop-client relay (previous behaviour) |
|
|
12
|
+
*
|
|
13
|
+
* "Same network" is decided by **reachability**, not by comparing IP prefixes:
|
|
14
|
+
* a machine on `192.168.31.x` may still route straight to a NAS on
|
|
15
|
+
* `<nas-ip>`, and a machine on the NAS's own subnet may have WebDAV
|
|
16
|
+
* disabled. A 1.5s PROPFIND answers that question directly.
|
|
17
|
+
*
|
|
18
|
+
* Runtime failures on the WebDAV path (timeouts, refused connections, 5xx)
|
|
19
|
+
* mark it down for a cooldown and retry the same call over the relay, so losing
|
|
20
|
+
* the LAN mid-session only costs latency — never a failed tool call. Business
|
|
21
|
+
* errors (401/403/404/405/409, `N00…` codes) are *not* transport failures and
|
|
22
|
+
* are returned as-is.
|
|
23
|
+
*
|
|
24
|
+
* @module dsh-zspace/client/router
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import * as relay from "./relay.js";
|
|
28
|
+
import { ZSpaceError } from "./errors.js";
|
|
29
|
+
import * as webdav from "./webdav.js";
|
|
30
|
+
|
|
31
|
+
/** How long a probe result is trusted. */
|
|
32
|
+
const PROBE_TTL_MS = 60_000;
|
|
33
|
+
|
|
34
|
+
/** How long to stay on the relay after a WebDAV runtime failure. */
|
|
35
|
+
const DOWN_COOLDOWN_MS = 30_000;
|
|
36
|
+
|
|
37
|
+
/** Methods served by a transport (the rest are relay internals). */
|
|
38
|
+
export const ROUTED_METHODS = [
|
|
39
|
+
"check",
|
|
40
|
+
"pools",
|
|
41
|
+
"isDirectory",
|
|
42
|
+
"homePath",
|
|
43
|
+
"publicPath",
|
|
44
|
+
"list",
|
|
45
|
+
"info",
|
|
46
|
+
"mkdir",
|
|
47
|
+
"rename",
|
|
48
|
+
"move",
|
|
49
|
+
"copy",
|
|
50
|
+
"remove",
|
|
51
|
+
"download",
|
|
52
|
+
"readFile",
|
|
53
|
+
"upload",
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Whether an error means "this transport is unusable right now" (as opposed to
|
|
58
|
+
* a legitimate answer about the path or the credentials).
|
|
59
|
+
*
|
|
60
|
+
* @param {unknown} error - thrown value.
|
|
61
|
+
* @returns {boolean} true when falling back to the relay is the right move.
|
|
62
|
+
*/
|
|
63
|
+
export function isTransportFailure(error) {
|
|
64
|
+
const code = String(/** @type {{code?: string}} */ (error)?.code ?? "");
|
|
65
|
+
if (["ETIMEDOUT", "EDAV", "EPROXY", "ECONNREFUSED", "ECONNRESET", "ENETUNREACH", "EAI_AGAIN"].includes(code)) return true;
|
|
66
|
+
if (/^HTTP(5\d\d)$/.test(code)) return true;
|
|
67
|
+
return error instanceof TypeError; // undici network failures
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Which transport should serve the next call.
|
|
72
|
+
*
|
|
73
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
74
|
+
* @returns {Promise<"webdav"|"relay">} chosen transport.
|
|
75
|
+
*/
|
|
76
|
+
export async function selectTransport(client) {
|
|
77
|
+
const mode = client.transportMode || "auto";
|
|
78
|
+
if (mode !== "webdav" && mode !== "auto") return "relay";
|
|
79
|
+
if (!webdav.configured(client)) return "relay";
|
|
80
|
+
if (mode === "webdav") return "webdav";
|
|
81
|
+
if (client._webdavDownUntil && Date.now() < client._webdavDownUntil) return "relay";
|
|
82
|
+
|
|
83
|
+
const cached = client._webdavProbe;
|
|
84
|
+
if (cached && Date.now() - cached.at < PROBE_TTL_MS) return cached.usable ? "webdav" : "relay";
|
|
85
|
+
|
|
86
|
+
const result = await webdav.probe(client);
|
|
87
|
+
client._webdavProbe = { at: Date.now(), ...result };
|
|
88
|
+
return result.usable ? "webdav" : "relay";
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Human-readable transport state for `zspace_status`.
|
|
93
|
+
*
|
|
94
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
95
|
+
* @returns {Promise<{transport: string, configured: boolean, probe: Record<string, any>|null}>} report.
|
|
96
|
+
*/
|
|
97
|
+
export async function transportReport(client) {
|
|
98
|
+
const transport = await selectTransport(client);
|
|
99
|
+
return {
|
|
100
|
+
transport,
|
|
101
|
+
configured: webdav.configured(client),
|
|
102
|
+
probe: client._webdavProbe ?? null,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Run one capability through the selected transport, with relay fallback.
|
|
108
|
+
*
|
|
109
|
+
* @param {import("../client.js").ZSpaceClient} client - live client.
|
|
110
|
+
* @param {string} method - capability name (see {@link ROUTED_METHODS}).
|
|
111
|
+
* @param {unknown[]} args - arguments after the client.
|
|
112
|
+
* @returns {Promise<any>} capability result.
|
|
113
|
+
*/
|
|
114
|
+
export async function dispatch(client, method, args) {
|
|
115
|
+
const transport = await selectTransport(client);
|
|
116
|
+
const implementation = transport === "webdav" ? webdav : relay;
|
|
117
|
+
const fn = /** @type {Record<string, Function>} */ (implementation)[method];
|
|
118
|
+
if (typeof fn !== "function") throw new ZSpaceError("ENOTIMPL", `传输 ${transport} 未实现 ${method}`);
|
|
119
|
+
try {
|
|
120
|
+
return await fn(client, ...args);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (transport !== "webdav" || client.transportMode === "webdav" || !isTransportFailure(error)) throw error;
|
|
123
|
+
client._webdavDownUntil = Date.now() + DOWN_COOLDOWN_MS;
|
|
124
|
+
client._webdavProbe = {
|
|
125
|
+
at: Date.now(),
|
|
126
|
+
reachable: false,
|
|
127
|
+
usable: false,
|
|
128
|
+
reason: `运行中失败,${Math.round(DOWN_COOLDOWN_MS / 1000)}s 内走中转:${error?.message ?? error}`,
|
|
129
|
+
};
|
|
130
|
+
return await /** @type {Record<string, Function>} */ (relay)[method](client, ...args);
|
|
131
|
+
}
|
|
132
|
+
}
|
package/lib/client/session.js
CHANGED
|
@@ -22,13 +22,6 @@ export function authHeaders(client) {
|
|
|
22
22
|
};
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
/**
|
|
26
|
-
* Common form fields. These belong in the request **body**; the NAS rejects
|
|
27
|
-
* them in the query string with `N001212`.
|
|
28
|
-
*
|
|
29
|
-
* @returns {Record<string, string>} common fields.
|
|
30
|
-
*/
|
|
31
|
-
|
|
32
25
|
/**
|
|
33
26
|
* Common form fields. These belong in the request **body**; the NAS rejects
|
|
34
27
|
* them in the query string with `N001212`.
|
package/lib/client/spaces.js
CHANGED
|
@@ -27,12 +27,6 @@ export async function check(client) {
|
|
|
27
27
|
});
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
/**
|
|
31
|
-
* Storage pools (`/zspool/info`), cached per client.
|
|
32
|
-
*
|
|
33
|
-
* @returns {Promise<Array<{name: string, status: string, totalSize: number, freeSize: number}>>} pools.
|
|
34
|
-
*/
|
|
35
|
-
|
|
36
30
|
/**
|
|
37
31
|
* Storage pools (`/zspool/info`), cached per client.
|
|
38
32
|
*
|
|
@@ -51,13 +45,6 @@ export async function pools(client) {
|
|
|
51
45
|
return client._pools;
|
|
52
46
|
}
|
|
53
47
|
|
|
54
|
-
/**
|
|
55
|
-
* Whether a remote directory can be listed.
|
|
56
|
-
*
|
|
57
|
-
* @param {string} remotePath - absolute remote path.
|
|
58
|
-
* @returns {Promise<boolean>} true when listing succeeds.
|
|
59
|
-
*/
|
|
60
|
-
|
|
61
48
|
/**
|
|
62
49
|
* Whether a remote directory can be listed.
|
|
63
50
|
*
|
|
@@ -74,17 +61,7 @@ export async function isDirectory(client, remotePath) {
|
|
|
74
61
|
}
|
|
75
62
|
|
|
76
63
|
/**
|
|
77
|
-
* Locate the personal-space root, e.g.
|
|
78
|
-
*
|
|
79
|
-
* Uses the configured path when set, otherwise tries `/<pool>/my/data` for
|
|
80
|
-
* every storage pool and falls back to the historical `/sata11/my/data`.
|
|
81
|
-
*
|
|
82
|
-
* @returns {Promise<string>} personal-space root.
|
|
83
|
-
* @throws {ZSpaceError} when no candidate is listable.
|
|
84
|
-
*/
|
|
85
|
-
|
|
86
|
-
/**
|
|
87
|
-
* Locate the personal-space root, e.g. `/sata1/my/data`.
|
|
64
|
+
* Locate the personal-space root, e.g. `/<pool>/my/data`.
|
|
88
65
|
*
|
|
89
66
|
* Uses the configured path when set, otherwise tries `/<pool>/my/data` for
|
|
90
67
|
* every storage pool and falls back to the historical `/sata11/my/data`.
|
|
@@ -111,14 +88,7 @@ export async function homePath(client) {
|
|
|
111
88
|
}
|
|
112
89
|
|
|
113
90
|
/**
|
|
114
|
-
* Locate the public-space root, e.g.
|
|
115
|
-
*
|
|
116
|
-
* @returns {Promise<string>} public-space root.
|
|
117
|
-
* @throws {ZSpaceError} when no candidate is listable (account may lack access).
|
|
118
|
-
*/
|
|
119
|
-
|
|
120
|
-
/**
|
|
121
|
-
* Locate the public-space root, e.g. `/sata1/public`.
|
|
91
|
+
* Locate the public-space root, e.g. `/<pool>/public`.
|
|
122
92
|
*
|
|
123
93
|
* @returns {Promise<string>} public-space root.
|
|
124
94
|
* @throws {ZSpaceError} when no candidate is listable (account may lack access).
|
package/lib/client/transport.js
CHANGED
|
@@ -90,13 +90,6 @@ export function endpointUrl(client, endpoint, query) {
|
|
|
90
90
|
return url;
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
/**
|
|
94
|
-
* Encode form fields, expanding arrays into repeated `key[]` entries.
|
|
95
|
-
*
|
|
96
|
-
* @param {Record<string, unknown>} fields - form fields.
|
|
97
|
-
* @returns {string} urlencoded body.
|
|
98
|
-
*/
|
|
99
|
-
|
|
100
93
|
/**
|
|
101
94
|
* Encode form fields, expanding arrays into repeated `key[]` entries.
|
|
102
95
|
*
|
|
@@ -116,15 +109,6 @@ export function formBody(client, fields) {
|
|
|
116
109
|
return parts.join("&");
|
|
117
110
|
}
|
|
118
111
|
|
|
119
|
-
/**
|
|
120
|
-
* Retry a transport call with exponential backoff.
|
|
121
|
-
*
|
|
122
|
-
* @template T
|
|
123
|
-
* @param {() => Promise<T>} attempt - one attempt.
|
|
124
|
-
* @param {(error: unknown) => boolean} shouldRetry - retry predicate.
|
|
125
|
-
* @returns {Promise<T>} the first successful result.
|
|
126
|
-
*/
|
|
127
|
-
|
|
128
112
|
/**
|
|
129
113
|
* Retry a transport call with exponential backoff.
|
|
130
114
|
*
|
|
@@ -147,23 +131,6 @@ export async function retrying(client, attempt, shouldRetry) {
|
|
|
147
131
|
throw lastError;
|
|
148
132
|
}
|
|
149
133
|
|
|
150
|
-
/**
|
|
151
|
-
* Send one buffered request.
|
|
152
|
-
*
|
|
153
|
-
* @param {object} options - request options.
|
|
154
|
-
* @param {"GET"|"POST"} [options.method] - HTTP method.
|
|
155
|
-
* @param {string} options.endpoint - API path.
|
|
156
|
-
* @param {Record<string, unknown>} [options.query] - extra query values.
|
|
157
|
-
* @param {Record<string, unknown>} [options.form] - form fields (merged with common params).
|
|
158
|
-
* @param {Record<string, unknown>} [options.fields] - raw fields appended without common params.
|
|
159
|
-
* @param {Buffer} [options.body] - raw request body.
|
|
160
|
-
* @param {string} [options.filePath] - stream this file as the request body.
|
|
161
|
-
* @param {Record<string, string>} [options.headers] - extra headers (override auth).
|
|
162
|
-
* @param {string} [options.url] - absolute URL override (sliced upload).
|
|
163
|
-
* @param {number} [options.timeoutMs] - per-attempt timeout.
|
|
164
|
-
* @returns {Promise<{status: number, headers: import("node:http").IncomingHttpHeaders, text: string}>} response.
|
|
165
|
-
*/
|
|
166
|
-
|
|
167
134
|
/**
|
|
168
135
|
* Send one buffered request.
|
|
169
136
|
*
|
|
@@ -268,15 +235,6 @@ export async function send(client, options) {
|
|
|
268
235
|
});
|
|
269
236
|
}
|
|
270
237
|
|
|
271
|
-
/**
|
|
272
|
-
* Validate a buffered JSON response and unwrap its `data`.
|
|
273
|
-
*
|
|
274
|
-
* @param {{status: number, text: string}} response - raw response.
|
|
275
|
-
* @param {string} endpoint - endpoint for error context.
|
|
276
|
-
* @returns {any} the response `data` (or the whole body when it has none).
|
|
277
|
-
* @throws {ZSpaceError} on HTTP failures, HTTP 413 and non-200 business codes.
|
|
278
|
-
*/
|
|
279
|
-
|
|
280
238
|
/**
|
|
281
239
|
* Validate a buffered JSON response and unwrap its `data`.
|
|
282
240
|
*
|
|
@@ -306,14 +264,6 @@ export function parse(client, response, endpoint) {
|
|
|
306
264
|
return body.data !== undefined ? body.data : body;
|
|
307
265
|
}
|
|
308
266
|
|
|
309
|
-
/**
|
|
310
|
-
* POST form fields and return the unwrapped `data`.
|
|
311
|
-
*
|
|
312
|
-
* @param {string} endpoint - API path.
|
|
313
|
-
* @param {Record<string, unknown>} [fields] - form fields.
|
|
314
|
-
* @returns {Promise<any>} response data.
|
|
315
|
-
*/
|
|
316
|
-
|
|
317
267
|
/**
|
|
318
268
|
* POST form fields and return the unwrapped `data`.
|
|
319
269
|
*
|
package/lib/client/upload.js
CHANGED
|
@@ -52,18 +52,6 @@ export async function upload(client, localPath, remoteDir, options = {}) {
|
|
|
52
52
|
}
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
/**
|
|
56
|
-
* Sliced upload (`/v2/file/upload`), the protocol the desktop client uses
|
|
57
|
-
* for anything the local proxy will not pass through in one request.
|
|
58
|
-
*
|
|
59
|
-
* @param {string} localPath - local file.
|
|
60
|
-
* @param {string} target - absolute remote file path.
|
|
61
|
-
* @param {number} total - total byte size.
|
|
62
|
-
* @param {import("node:fs").Stats} stat - local file stat.
|
|
63
|
-
* @param {(sent: number, total: number) => void} [onProgress] - progress callback.
|
|
64
|
-
* @returns {Promise<void>} resolves when the last slice was accepted.
|
|
65
|
-
*/
|
|
66
|
-
|
|
67
55
|
/**
|
|
68
56
|
* Sliced upload (`/v2/file/upload`), the protocol the desktop client uses
|
|
69
57
|
* for anything the local proxy will not pass through in one request.
|