dsh-zspace 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -1,159 +1,38 @@
1
1
  /**
2
- * Minimal, dependency-free ZSpace (极空间) NAS client.
2
+ * 极空间 (ZSpace) NAS client — the public entry point.
3
3
  *
4
- * Everything goes through the **desktop client's local proxy**
5
- * (`http://127.0.0.1:13579`), which relays to the NAS over 极空间's cloud /
6
- * P2P channel. Working from another network therefore needs no LAN address,
7
- * no DDNS, no WebDAV share and no SSH — only a logged-in desktop client on the
8
- * machine that runs DSH.
4
+ * Every call rides the **desktop client's local proxy** (`http://127.0.0.1:13579`),
5
+ * which relays to the NAS over 极空间's cloud/P2P channel, so the plugin works
6
+ * from any network without a LAN address, WebDAV or SSH.
9
7
  *
10
- * Protocol notes (community-reverse-engineered, verified against a live
11
- * Z4S / ZOS install on 2026-10-05):
8
+ * This file is a thin facade: it owns the mutable session state (base URL,
9
+ * timeouts, cached space roots) and delegates each capability to one focused
10
+ * module under `./client/`:
12
11
  *
13
- * - POST `application/x-www-form-urlencoded`; auth via cookies
14
- * (`token` / `zenithtoken` / `nas_id` / `nasid` / `device_id`) plus the
15
- * `token` / `nasid` / `plat=web` / `version` / `device_id` / `_l` form fields.
16
- * The common fields must ride in the **body**; putting them in the query
17
- * string makes the NAS answer `N001212 参数有误`.
18
- * - Every URL carries `?&rnd=<ts>_<rand>&webagent=v2`.
19
- * - `/v2/file/list` returns at most 50 rows, so long directories are paged.
20
- * - `move` / `copy` / `remove` take repeated `paths[]` fields and a `to`.
21
- * - Small files upload through `/v2/file/create` with the target path in a
22
- * **percent-encoded `path` header**; bodies above the local proxy's limit
23
- * answer HTTP 413 and must use the desktop client's sliced
24
- * `/v2/file/upload` protocol instead.
12
+ * | module | capability |
13
+ * |---|---|
14
+ * | `client/errors.js` | NAS business codes and the error type |
15
+ * | `client/transport.js` | URL shaping, form encoding, retries, response validation |
16
+ * | `client/session.js` | cookies, common params, safe identity |
17
+ * | `client/spaces.js` | proxy probe, storage pools, space roots |
18
+ * | `client/browse.js` | paged listing, entry metadata |
19
+ * | `client/mutate.js` | mkdir / rename / move / copy / remove |
20
+ * | `client/upload.js` | single-request and sliced uploads |
21
+ * | `client/download.js` | streamed download, capped reads |
25
22
  *
26
23
  * @module dsh-zspace/client
27
24
  */
28
25
 
29
- import crypto from "node:crypto";
30
- import fs from "node:fs";
31
- import fsp from "node:fs/promises";
32
- import http from "node:http";
33
- import https from "node:https";
34
- import path from "node:path";
35
- import { pipeline } from "node:stream/promises";
36
-
37
26
  import { DEFAULT_API_VERSION, loadCredentials, resolveBaseUrl } from "./auth.js";
38
- import { joinRemote, splitRemote, toEntry } from "./format.js";
39
-
40
- const FORM_TYPE = "application/x-www-form-urlencoded";
41
- const DEFAULT_PAGE_SIZE = 50;
42
-
43
- /**
44
- * Business codes the desktop client treats as fatal during sliced uploads —
45
- * retrying them only burns time (permission / conflicting dir / safe-box).
46
- */
47
- const UPLOAD_FATAL_CODES = new Set(["N001302", "N001331", "N001603", "N001397"]);
48
-
49
- /** Network errno values worth a retry; a down client is not among them. */
50
- const RETRYABLE_ERRNOS = new Set(["ECONNRESET", "ETIMEDOUT", "EPIPE", "EAI_AGAIN", "ENETUNREACH"]);
51
-
52
- /**
53
- * One ZSpace API or transport failure.
54
- *
55
- * `code` is the NAS business code (`N001411`, …) when the response parsed,
56
- * or a synthetic marker (`HTTP413`, `EPROXY`, …) otherwise.
57
- */
58
- export class ZSpaceError extends Error {
59
- /**
60
- * @param {string} code - NAS business code or synthetic marker.
61
- * @param {string} message - human-readable message.
62
- * @param {{endpoint?: string, cause?: unknown}} [options] - context.
63
- */
64
- constructor(code, message, options = {}) {
65
- super(message, options.cause === undefined ? undefined : { cause: options.cause });
66
- this.name = "ZSpaceError";
67
- this.code = String(code);
68
- this.endpoint = options.endpoint ?? "";
69
- }
70
-
71
- /**
72
- * Actionable next step for known codes, or `undefined`.
73
- *
74
- * @returns {string|undefined} hint text.
75
- */
76
- get hint() {
77
- const message = this.message;
78
- if (this.code === "N001411" || /无权限/.test(message)) {
79
- return "没有权限:确认该账号对目标路径可访问(「/」与裸盘根目录总是拒绝的,先 ls 已知的空间根,例如 home: 或 public:)。";
80
- }
81
- if (this.code === "N001315" || /不存在/.test(message)) {
82
- return "路径不存在:先列出上级目录确认拼写,注意大小写与中文全角字符。";
83
- }
84
- if (this.code === "N001212") {
85
- return "参数有误:路径必须以 / 开头(或用 home: / public: / 相对路径)。";
86
- }
87
- if (this.code === "HTTP413") {
88
- return "本地代理拒绝超大请求体:大文件会自动改走分片上传,若仍失败请降低分片大小。";
89
- }
90
- if (this.code === "EPROXY") {
91
- return "连不上极空间桌面客户端本地代理:确认客户端已安装、已登录并在运行(端口 13579)。";
92
- }
93
- return undefined;
94
- }
95
- }
96
-
97
- /**
98
- * Random token appended to every request URL.
99
- *
100
- * @returns {string} `?&rnd=` value.
101
- */
102
- function randomToken() {
103
- return `${Date.now()}${Math.floor(Math.random() * 9000 + 1000)}_${Math.floor(Math.random() * 9000 + 1000)}`;
104
- }
27
+ import { identity, authHeaders, commonParams } from "./client/session.js";
28
+ import { check, pools, isDirectory, homePath, publicPath } from "./client/spaces.js";
29
+ import { list, info } from "./client/browse.js";
30
+ import { mkdir, rename, move, copy, remove } from "./client/mutate.js";
31
+ import { upload, uploadSliced } from "./client/upload.js";
32
+ import { download, readFile, openDownload } from "./client/download.js";
33
+ import { endpointUrl, formBody, retrying, send, parse, post, DEFAULT_PAGE_SIZE } from "./client/transport.js";
34
+ export { ZSpaceError } from "./client/errors.js";
105
35
 
106
- /**
107
- * Percent-encode one value for a form field, query value or upload header.
108
- *
109
- * @param {unknown} value - value to encode.
110
- * @returns {string} encoded value.
111
- */
112
- function percentEncode(value) {
113
- return encodeURIComponent(String(value));
114
- }
115
-
116
- /**
117
- * Whether a transport error is worth retrying.
118
- *
119
- * @param {unknown} error - thrown value.
120
- * @returns {boolean} true when a retry may succeed.
121
- */
122
- function isRetryableTransportError(error) {
123
- const errno = /** @type {{code?: string, name?: string}} */ (error)?.code;
124
- if (errno === "ECONNREFUSED" || errno === "ENOTFOUND" || errno === "EHOSTUNREACH") return false;
125
- if (errno !== undefined && RETRYABLE_ERRNOS.has(errno)) return true;
126
- return false;
127
- }
128
-
129
- /**
130
- * Read a whole stream with a byte cap.
131
- *
132
- * @param {import("node:stream").Readable} stream - source stream.
133
- * @param {number} [maxBytes] - stop after this many bytes.
134
- * @returns {Promise<{buffer: Buffer, truncated: boolean}>} collected bytes.
135
- */
136
- async function readStream(stream, maxBytes = Number.POSITIVE_INFINITY) {
137
- const chunks = [];
138
- let total = 0;
139
- let truncated = false;
140
- for await (const chunk of stream) {
141
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
142
- if (total + buffer.length > maxBytes) {
143
- chunks.push(buffer.subarray(0, Math.max(0, maxBytes - total)));
144
- total = maxBytes;
145
- truncated = true;
146
- break;
147
- }
148
- chunks.push(buffer);
149
- total += buffer.length;
150
- }
151
- return { buffer: Buffer.concat(chunks), truncated };
152
- }
153
-
154
- /**
155
- * Cross-network ZSpace NAS client.
156
- */
157
36
  export class ZSpaceClient {
158
37
  /**
159
38
  * @param {object} [options] - client options.
@@ -205,653 +84,108 @@ export class ZSpaceClient {
205
84
  *
206
85
  * @returns {{username: string, nasId: string, deviceId: string, vuexPath: string}} safe identity fields.
207
86
  */
87
+
208
88
  get identity() {
209
- const creds = this.credentials;
210
- return {
211
- username: creds.username ?? "",
212
- nasId: creds.nasId ?? "",
213
- deviceId: creds.deviceId ?? "",
214
- vuexPath: creds.vuexPath ?? "",
215
- };
89
+ return identity(this);
216
90
  }
217
91
 
218
- // ── transport ────────────────────────────────────────────────────────────
219
-
220
- /**
221
- * Auth cookies for every request.
222
- *
223
- * @returns {Record<string, string>} request headers carrying the session.
224
- */
225
92
  authHeaders() {
226
- const creds = this.credentials;
227
- return {
228
- Cookie: [
229
- `token=${creds.token}`,
230
- `zenithtoken=${creds.token}`,
231
- `nas_id=${creds.nasId}`,
232
- `nasid=${creds.nasId}`,
233
- `device_id=${creds.deviceId}`,
234
- ].join("; "),
235
- };
93
+ return authHeaders(this);
236
94
  }
237
95
 
238
- /**
239
- * Common form fields. These belong in the request **body**; the NAS rejects
240
- * them in the query string with `N001212`.
241
- *
242
- * @returns {Record<string, string>} common fields.
243
- */
244
96
  commonParams() {
245
- const creds = this.credentials;
246
- return {
247
- token: creds.token,
248
- nasid: creds.nasId,
249
- plat: "web",
250
- version: this.apiVersion,
251
- device_id: creds.deviceId,
252
- _l: "zh_cn",
253
- };
97
+ return commonParams(this);
254
98
  }
255
99
 
256
- /**
257
- * Build an endpoint URL with the mandatory random query suffix.
258
- *
259
- * @param {string} endpoint - API path, e.g. `/v2/file/list`.
260
- * @param {Record<string, unknown>} [query] - extra query values.
261
- * @returns {string} absolute URL.
262
- */
263
- endpointUrl(endpoint, query) {
264
- let url = `${this.baseUrl}${endpoint}?&rnd=${randomToken()}&webagent=v2`;
265
- for (const [key, value] of Object.entries(query ?? {})) {
266
- if (value === undefined || value === null) continue;
267
- url += `&${key}=${percentEncode(value)}`;
268
- }
269
- return url;
270
- }
271
-
272
- /**
273
- * Encode form fields, expanding arrays into repeated `key[]` entries.
274
- *
275
- * @param {Record<string, unknown>} fields - form fields.
276
- * @returns {string} urlencoded body.
277
- */
278
- formBody(fields) {
279
- const parts = [];
280
- for (const [key, value] of Object.entries({ ...this.commonParams(), ...fields })) {
281
- if (value === undefined || value === null) continue;
282
- if (Array.isArray(value)) {
283
- for (const item of value) parts.push(`${percentEncode(key)}%5B%5D=${percentEncode(item)}`);
284
- } else {
285
- parts.push(`${percentEncode(key)}=${percentEncode(value)}`);
286
- }
287
- }
288
- return parts.join("&");
289
- }
290
-
291
- /**
292
- * Retry a transport call with exponential backoff.
293
- *
294
- * @template T
295
- * @param {() => Promise<T>} attempt - one attempt.
296
- * @param {(error: unknown) => boolean} shouldRetry - retry predicate.
297
- * @returns {Promise<T>} the first successful result.
298
- */
299
- async retrying(attempt, shouldRetry) {
300
- let lastError;
301
- for (let index = 0; index <= this.maxRetries; index += 1) {
302
- try {
303
- return await attempt();
304
- } catch (error) {
305
- lastError = error;
306
- if (index >= this.maxRetries || !shouldRetry(error)) throw error;
307
- await new Promise(resolve => setTimeout(resolve, Math.min(this.retryDelayMs * 2 ** index, 2000)));
308
- }
309
- }
310
- throw lastError;
311
- }
312
-
313
- /**
314
- * Send one buffered request.
315
- *
316
- * @param {object} options - request options.
317
- * @param {"GET"|"POST"} [options.method] - HTTP method.
318
- * @param {string} options.endpoint - API path.
319
- * @param {Record<string, unknown>} [options.query] - extra query values.
320
- * @param {Record<string, unknown>} [options.form] - form fields (merged with common params).
321
- * @param {Record<string, unknown>} [options.fields] - raw fields appended without common params.
322
- * @param {Buffer} [options.body] - raw request body.
323
- * @param {string} [options.filePath] - stream this file as the request body.
324
- * @param {Record<string, string>} [options.headers] - extra headers (override auth).
325
- * @param {string} [options.url] - absolute URL override (sliced upload).
326
- * @param {number} [options.timeoutMs] - per-attempt timeout.
327
- * @returns {Promise<{status: number, headers: import("node:http").IncomingHttpHeaders, text: string}>} response.
328
- */
329
- async send(options) {
330
- const attempt = async () => {
331
- const url = options.url ?? this.endpointUrl(options.endpoint, options.query);
332
- const headers = { ...this.authHeaders(), ...(options.headers ?? {}) };
333
- let payload;
334
- if (options.form !== undefined || options.fields !== undefined) {
335
- payload = Buffer.from(
336
- options.form !== undefined
337
- ? this.formBody(options.form)
338
- : Object.entries(options.fields ?? {})
339
- .map(([key, value]) => `${percentEncode(key)}=${percentEncode(value)}`)
340
- .join("&"),
341
- "utf8",
342
- );
343
- headers["Content-Type"] = headers["Content-Type"] ?? FORM_TYPE;
344
- headers["Content-Length"] = String(payload.length);
345
- } else if (options.filePath !== undefined) {
346
- const stat = await fsp.stat(options.filePath);
347
- headers["Content-Length"] = String(stat.size);
348
- } else if (options.body !== undefined) {
349
- payload = options.body;
350
- headers["Content-Length"] = headers["Content-Length"] ?? String(payload.length);
351
- }
352
-
353
- return await new Promise((resolve, reject) => {
354
- const parsed = new URL(url);
355
- const transport = parsed.protocol === "https:" ? https : http;
356
- const request = transport.request(
357
- url,
358
- { method: options.method ?? "POST", headers },
359
- (response) => {
360
- readStream(response, 64 * 1024 * 1024).then(
361
- ({ buffer }) => {
362
- const status = response.statusCode ?? 0;
363
- // 5xx/429 mean the proxy hiccuped, not that the call was
364
- // wrong — surface them as retryable failures here, where the
365
- // retry loop can still see them.
366
- if (status >= 500 || status === 429) {
367
- reject(
368
- new ZSpaceError(`HTTP${status}`, `HTTP ${status} on ${options.endpoint ?? url}: ${buffer.toString("utf8").slice(0, 200)}`, {
369
- endpoint: options.endpoint ?? url,
370
- }),
371
- );
372
- return;
373
- }
374
- resolve({ status, headers: response.headers, text: buffer.toString("utf8") });
375
- },
376
- reject,
377
- );
378
- },
379
- );
380
- request.setTimeout(options.timeoutMs ?? this.timeoutMs, () => {
381
- request.destroy(new ZSpaceError("ETIMEDOUT", `请求超时(${options.endpoint ?? options.url})`));
382
- });
383
- request.on("error", (error) => {
384
- if (error instanceof ZSpaceError) {
385
- reject(error);
386
- return;
387
- }
388
- reject(
389
- new ZSpaceError("EPROXY", `无法访问极空间桌面客户端代理 ${this.baseUrl}:${error.message}`, {
390
- endpoint: options.endpoint ?? url,
391
- cause: error,
392
- }),
393
- );
394
- });
395
- if (options.filePath !== undefined) {
396
- const source = fs.createReadStream(options.filePath);
397
- source.on("error", reject);
398
- source.pipe(request);
399
- } else {
400
- request.end(payload);
401
- }
402
- });
403
- };
404
-
405
- return await this.retrying(attempt, error => {
406
- if (error instanceof ZSpaceError) {
407
- if (error.code === "ETIMEDOUT") return true;
408
- if (error.code === "EPROXY") return isRetryableTransportError(error.cause);
409
- // The desktop proxy hiccups with 5xx/429 now and then; the NAS's
410
- // business codes (N00…) are deterministic and never retried here.
411
- return /^HTTP(5\d\d|429)$/.test(error.code);
412
- }
413
- return isRetryableTransportError(error);
414
- });
415
- }
416
-
417
- /**
418
- * Validate a buffered JSON response and unwrap its `data`.
419
- *
420
- * @param {{status: number, text: string}} response - raw response.
421
- * @param {string} endpoint - endpoint for error context.
422
- * @returns {any} the response `data` (or the whole body when it has none).
423
- * @throws {ZSpaceError} on HTTP failures, HTTP 413 and non-200 business codes.
424
- */
425
- parse(response, endpoint) {
426
- const { status, text } = response;
427
- if (status === 413) {
428
- throw new ZSpaceError("HTTP413", `本地代理拒绝该请求体(HTTP 413)`, { endpoint });
429
- }
430
- if (status >= 400) {
431
- throw new ZSpaceError(`HTTP${status}`, `HTTP ${status} on ${endpoint}: ${text.slice(0, 200)}`, { endpoint });
432
- }
433
- let body;
434
- try {
435
- body = JSON.parse(text);
436
- } catch {
437
- throw new ZSpaceError("BAD_JSON", `${endpoint} 返回了非 JSON 内容:${text.slice(0, 200)}`, { endpoint });
438
- }
439
- const code = body.code ?? body.errcode;
440
- if (code !== undefined && String(code) !== "200") {
441
- throw new ZSpaceError(String(code), `${body.msg ?? body.message ?? "error"}(${endpoint})`, { endpoint });
442
- }
443
- return body.data !== undefined ? body.data : body;
444
- }
445
-
446
- /**
447
- * POST form fields and return the unwrapped `data`.
448
- *
449
- * @param {string} endpoint - API path.
450
- * @param {Record<string, unknown>} [fields] - form fields.
451
- * @returns {Promise<any>} response data.
452
- */
453
- async post(endpoint, fields) {
454
- const response = await this.send({ method: "POST", endpoint, form: fields ?? {} });
455
- return this.parse(response, endpoint);
456
- }
457
-
458
- // ── connectivity and spaces ──────────────────────────────────────────────
459
-
460
- /**
461
- * Probe the desktop client proxy.
462
- *
463
- * @returns {Promise<boolean>} true when the proxy answers below 500.
464
- */
465
100
  async check() {
466
- const url = `${this.baseUrl}/home/`;
467
- return await new Promise((resolve) => {
468
- const parsed = new URL(url);
469
- const transport = parsed.protocol === "https:" ? https : http;
470
- const request = transport.get(url, { timeout: 3000 }, (response) => {
471
- response.resume();
472
- resolve((response.statusCode ?? 500) < 500);
473
- });
474
- request.setTimeout(3000, () => request.destroy());
475
- request.on("error", () => resolve(false));
476
- });
101
+ return await check(this);
477
102
  }
478
103
 
479
- /**
480
- * Storage pools (`/zspool/info`), cached per client.
481
- *
482
- * @returns {Promise<Array<{name: string, status: string, totalSize: number, freeSize: number}>>} pools.
483
- */
484
104
  async pools() {
485
- if (this._pools !== null) return this._pools;
486
- const data = await this.post("/zspool/info", {});
487
- const list = Array.isArray(data?.pool_list) ? data.pool_list : [];
488
- this._pools = list.map((pool) => ({
489
- name: String(pool.name ?? ""),
490
- status: String(pool.status ?? ""),
491
- totalSize: Number(pool.total_size ?? 0),
492
- freeSize: Number(pool.free_size ?? 0),
493
- }));
494
- return this._pools;
105
+ return await pools(this);
495
106
  }
496
107
 
497
- /**
498
- * Whether a remote directory can be listed.
499
- *
500
- * @param {string} remotePath - absolute remote path.
501
- * @returns {Promise<boolean>} true when listing succeeds.
502
- */
503
108
  async isDirectory(remotePath) {
504
- try {
505
- await this.post("/v2/file/list", { path: remotePath, show_hidden: "0", start: "0", limit: "1" });
506
- return true;
507
- } catch {
508
- return false;
509
- }
109
+ return await isDirectory(this, remotePath);
510
110
  }
511
111
 
512
- /**
513
- * Locate the personal-space root, e.g. `/sata1/my/data`.
514
- *
515
- * Uses the configured path when set, otherwise tries `/<pool>/my/data` for
516
- * every storage pool and falls back to the historical `/sata11/my/data`.
517
- *
518
- * @returns {Promise<string>} personal-space root.
519
- * @throws {ZSpaceError} when no candidate is listable.
520
- */
521
112
  async homePath() {
522
- if (this._homePath !== null) return this._homePath;
523
- const candidates = [];
524
- if (this.configuredHomePath) {
525
- candidates.push(this.configuredHomePath);
526
- } else {
527
- for (const pool of await this.pools().catch(() => [])) candidates.push(`/${pool.name}/my/data`);
528
- candidates.push("/sata11/my/data", "/sata1/my/data");
529
- }
530
- for (const candidate of [...new Set(candidates)]) {
531
- if (await this.isDirectory(candidate)) {
532
- this._homePath = candidate;
533
- return candidate;
534
- }
535
- }
536
- throw new ZSpaceError("NOHOME", `无法定位个人空间根目录,已尝试:${candidates.join("、")}`);
113
+ return await homePath(this);
537
114
  }
538
115
 
539
- /**
540
- * Locate the public-space root, e.g. `/sata1/public`.
541
- *
542
- * @returns {Promise<string>} public-space root.
543
- * @throws {ZSpaceError} when no candidate is listable (account may lack access).
544
- */
545
116
  async publicPath() {
546
- if (this._publicPath !== null) return this._publicPath;
547
- const candidates = [];
548
- if (this.configuredPublicPath) {
549
- candidates.push(this.configuredPublicPath);
550
- } else {
551
- for (const pool of await this.pools().catch(() => [])) candidates.push(`/${pool.name}/public`);
552
- candidates.push("/public", "/sata1/public");
553
- }
554
- for (const candidate of [...new Set(candidates)]) {
555
- if (await this.isDirectory(candidate)) {
556
- this._publicPath = candidate;
557
- return candidate;
558
- }
559
- }
560
- throw new ZSpaceError("NOPUBLIC", `无法定位公共空间(账号可能没有公共空间权限),已尝试:${candidates.join("、")}`);
117
+ return await publicPath(this);
561
118
  }
562
119
 
563
- // ── file operations ──────────────────────────────────────────────────────
564
-
565
- /**
566
- * List one directory, paging until the NAS has no more rows.
567
- *
568
- * @param {string} remotePath - absolute remote directory.
569
- * @param {{showHidden?: boolean, maxEntries?: number}} [options] - listing options.
570
- * @returns {Promise<{entries: Array<ReturnType<typeof toEntry>>, truncated: boolean}>} listing.
571
- */
572
120
  async list(remotePath, options = {}) {
573
- const maxEntries = options.maxEntries ?? this.listMaxEntries;
574
- /** @type {Array<ReturnType<typeof toEntry>>} */
575
- const entries = [];
576
- let start = 0;
577
- let truncated = false;
578
- for (;;) {
579
- const data = await this.post("/v2/file/list", {
580
- path: remotePath,
581
- show_hidden: options.showHidden ? "1" : "0",
582
- start: String(start),
583
- limit: String(this.pageSize),
584
- });
585
- const page = Array.isArray(data?.list) ? data.list : [];
586
- for (const row of page) entries.push(toEntry(row));
587
- if (page.length < this.pageSize) break;
588
- start += page.length;
589
- if (entries.length >= maxEntries) {
590
- truncated = true;
591
- break;
592
- }
593
- }
594
- if (entries.length > maxEntries) return { entries: entries.slice(0, maxEntries), truncated: true };
595
- return { entries, truncated };
121
+ return await list(this, remotePath, options);
596
122
  }
597
123
 
598
- /**
599
- * File or directory metadata.
600
- *
601
- * @param {string} remotePath - absolute remote path.
602
- * @returns {Promise<ReturnType<typeof toEntry>>} normalized entry.
603
- */
604
124
  async info(remotePath) {
605
- const data = await this.post("/v2/file/info", { path: remotePath });
606
- return toEntry(data ?? {});
125
+ return await info(this, remotePath);
607
126
  }
608
127
 
609
- /**
610
- * Create one directory.
611
- *
612
- * @param {string} remotePath - absolute path of the new directory.
613
- * @returns {Promise<ReturnType<typeof toEntry>>} the created entry.
614
- */
615
128
  async mkdir(remotePath) {
616
- const { parent, name } = splitRemote(remotePath);
617
- const data = await this.post("/v2/file/newdir", { parent, name, rename: "0" });
618
- return toEntry(data ?? {});
129
+ return await mkdir(this, remotePath);
619
130
  }
620
131
 
621
- /**
622
- * Rename a file or directory in place.
623
- *
624
- * @param {string} remotePath - absolute current path.
625
- * @param {string} newName - new base name (no directory part).
626
- * @returns {Promise<ReturnType<typeof toEntry>>} the renamed entry.
627
- */
628
132
  async rename(remotePath, newName) {
629
- const data = await this.post("/v2/file/modify", { path: remotePath, newname: newName });
630
- return toEntry(data ?? {});
133
+ return await rename(this, remotePath, newName);
631
134
  }
632
135
 
633
- /**
634
- * Move files or directories into a target directory.
635
- *
636
- * @param {string[]} remotePaths - absolute source paths.
637
- * @param {string} to - absolute destination directory.
638
- * @returns {Promise<void>} resolves when the NAS accepted the move.
639
- */
640
136
  async move(remotePaths, to) {
641
- await this.post("/v2/file/move", { paths: remotePaths, to });
137
+ return await move(this, remotePaths, to);
642
138
  }
643
139
 
644
- /**
645
- * Copy files or directories into a target directory.
646
- *
647
- * @param {string[]} remotePaths - absolute source paths.
648
- * @param {string} to - absolute destination directory.
649
- * @returns {Promise<void>} resolves when the NAS accepted the copy.
650
- */
651
140
  async copy(remotePaths, to) {
652
- await this.post("/v2/file/copy", { paths: remotePaths, to });
141
+ return await copy(this, remotePaths, to);
653
142
  }
654
143
 
655
- /**
656
- * Delete files or directories (the NAS moves them to its recycle bin).
657
- *
658
- * @param {string[]} remotePaths - absolute paths to remove.
659
- * @returns {Promise<void>} resolves when the NAS accepted the deletion.
660
- */
661
144
  async remove(remotePaths) {
662
- await this.post("/v2/file/remove", { paths: remotePaths });
145
+ return await remove(this, remotePaths);
663
146
  }
664
147
 
665
- /**
666
- * Download one file to a local directory.
667
- *
668
- * @param {string} remotePath - absolute remote file.
669
- * @param {string} localDir - local destination directory (created if needed).
670
- * @param {{name?: string}} [options] - destination name override.
671
- * @returns {Promise<{localPath: string, bytes: number}>} written file.
672
- */
673
- async download(remotePath, localDir, options = {}) {
674
- const name = options.name || path.posix.basename(remotePath);
675
- const dest = path.join(localDir, name);
676
- await fsp.mkdir(localDir, { recursive: true });
677
- const response = await this.openDownload(remotePath);
148
+ async upload(localPath, remoteDir, options = {}) {
149
+ return await upload(this, localPath, remoteDir, options);
150
+ }
678
151
 
679
- const status = response.statusCode ?? 0;
680
- const contentType = String(response.headers["content-type"] ?? "");
681
- if (status >= 400 || contentType.includes("json")) {
682
- const { buffer } = await readStream(response);
683
- this.parse({ status, text: buffer.toString("utf8") }, "/v2/file/download");
684
- }
685
- try {
686
- await pipeline(response, fs.createWriteStream(dest));
687
- } catch (error) {
688
- await fsp.rm(dest, { force: true });
689
- throw new ZSpaceError("EDOWNLOAD", `写入本地文件失败:${dest}(${error.message})`, { cause: error });
690
- }
691
- const stat = await fsp.stat(dest);
692
- return { localPath: dest, bytes: stat.size };
152
+ async uploadSliced(localPath, target, total, stat, onProgress) {
153
+ return await uploadSliced(this, localPath, target, total, stat, onProgress);
154
+ }
155
+
156
+ async download(remotePath, localDir, options = {}) {
157
+ return await download(this, remotePath, localDir, options);
693
158
  }
694
159
 
695
- /**
696
- * Read the head of a remote file into memory.
697
- *
698
- * @param {string} remotePath - absolute remote file.
699
- * @param {{maxBytes?: number}} [options] - byte cap.
700
- * @returns {Promise<{buffer: Buffer, truncated: boolean, bytes: number}>} content.
701
- */
702
160
  async readFile(remotePath, options = {}) {
703
- const maxBytes = options.maxBytes ?? 262_144;
704
- const response = await this.openDownload(remotePath);
705
- const status = response.statusCode ?? 0;
706
- const contentType = String(response.headers["content-type"] ?? "");
707
- if (status >= 400 || contentType.includes("json")) {
708
- const { buffer } = await readStream(response);
709
- this.parse({ status, text: buffer.toString("utf8") }, "/v2/file/download");
710
- }
711
- const { buffer, truncated } = await readStream(response, maxBytes);
712
- response.destroy();
713
- return { buffer, truncated, bytes: buffer.length };
161
+ return await readFile(this, remotePath, options);
714
162
  }
715
163
 
716
- /**
717
- * Open a download response, retrying transient proxy failures.
718
- *
719
- * @param {string} remotePath - absolute remote file.
720
- * @returns {Promise<import("node:http").IncomingMessage>} the open response (caller consumes it).
721
- */
722
164
  async openDownload(remotePath) {
723
- const url = this.endpointUrl("/v2/file/download", { path: remotePath, remote_port: "8050" });
724
- return await this.retrying(
725
- () =>
726
- new Promise((resolve, reject) => {
727
- const request = (this.baseUrl.startsWith("https:") ? https : http).request(
728
- url,
729
- { method: "GET", headers: this.authHeaders() },
730
- (response) => {
731
- const status = response.statusCode ?? 0;
732
- if (status >= 500 || status === 429) {
733
- response.resume();
734
- reject(new ZSpaceError(`HTTP${status}`, `HTTP ${status} 下载失败:${remotePath}`, { endpoint: "/v2/file/download" }));
735
- return;
736
- }
737
- resolve(response);
738
- },
739
- );
740
- request.setTimeout(this.timeoutMs, () => request.destroy(new ZSpaceError("ETIMEDOUT", `下载超时:${remotePath}`)));
741
- request.on("error", (error) => {
742
- if (error instanceof ZSpaceError) reject(error);
743
- else reject(new ZSpaceError("EPROXY", `下载失败(${remotePath}):${error.message}`, { endpoint: "/v2/file/download" }));
744
- });
745
- request.end();
746
- }),
747
- error =>
748
- error instanceof ZSpaceError &&
749
- (error.code === "ETIMEDOUT" || error.code === "EPROXY" || /^HTTP(5\d\d|429)$/.test(error.code)),
750
- );
165
+ return await openDownload(this, remotePath);
751
166
  }
752
167
 
753
- /**
754
- * Upload a local file into a remote directory.
755
- *
756
- * Files at or below `smallUploadMaxBytes` use `/v2/file/create`; larger ones
757
- * (and any request the local proxy rejects with HTTP 413) use the desktop
758
- * client's sliced `/v2/file/upload` protocol.
759
- *
760
- * @param {string} localPath - local file.
761
- * @param {string} remoteDir - absolute remote directory.
762
- * @param {{name?: string, onProgress?: (sent: number, total: number) => void}} [options] - upload options.
763
- * @returns {Promise<{remotePath: string, bytes: number, method: "create"|"sliced"}>} upload result.
764
- */
765
- async upload(localPath, remoteDir, options = {}) {
766
- const stat = await fsp.stat(localPath);
767
- if (!stat.isFile()) throw new ZSpaceError("ENOTFILE", `本地路径不是文件:${localPath}`);
768
- const name = options.name || path.basename(localPath);
769
- const target = joinRemote(remoteDir, name);
168
+ endpointUrl(endpoint, query) {
169
+ return endpointUrl(this, endpoint, query);
170
+ }
770
171
 
771
- if (stat.size > this.smallUploadMaxBytes) {
772
- await this.uploadSliced(localPath, target, stat.size, stat, options.onProgress);
773
- return { remotePath: target, bytes: stat.size, method: "sliced" };
774
- }
775
- try {
776
- const response = await this.send({
777
- method: "POST",
778
- endpoint: "/v2/file/create",
779
- headers: { "Content-Type": "application/octet-stream", path: percentEncode(target) },
780
- filePath: localPath,
781
- });
782
- this.parse(response, "/v2/file/create");
783
- options.onProgress?.(stat.size, stat.size);
784
- return { remotePath: target, bytes: stat.size, method: "create" };
785
- } catch (error) {
786
- if (error instanceof ZSpaceError && error.code === "HTTP413") {
787
- await this.uploadSliced(localPath, target, stat.size, stat, options.onProgress);
788
- return { remotePath: target, bytes: stat.size, method: "sliced" };
789
- }
790
- throw error;
791
- }
172
+ formBody(fields) {
173
+ return formBody(this, fields);
792
174
  }
793
175
 
794
- /**
795
- * Sliced upload (`/v2/file/upload`), the protocol the desktop client uses
796
- * for anything the local proxy will not pass through in one request.
797
- *
798
- * @param {string} localPath - local file.
799
- * @param {string} target - absolute remote file path.
800
- * @param {number} total - total byte size.
801
- * @param {import("node:fs").Stats} stat - local file stat.
802
- * @param {(sent: number, total: number) => void} [onProgress] - progress callback.
803
- * @returns {Promise<void>} resolves when the last slice was accepted.
804
- */
805
- async uploadSliced(localPath, target, total, stat, onProgress) {
806
- const creds = this.credentials;
807
- const mtimeMs = Math.ceil(stat.mtimeMs);
808
- const uuid = crypto.createHash("md5").update(`${mtimeMs + total}${target}`).digest("hex");
809
- const modifyTime = Math.ceil(mtimeMs / 1000);
810
- const handle = await fsp.open(localPath, "r");
811
- try {
812
- let sent = 0;
813
- while (sent < total) {
814
- const length = Math.min(this.sliceSize, total - sent);
815
- const buffer = Buffer.alloc(length);
816
- await handle.read(buffer, 0, length, sent);
817
- const params = {
818
- app: "file",
819
- path: percentEncode(target),
820
- size: total,
821
- uuid,
822
- seek: sent,
823
- crtime: "",
824
- modify_time: modifyTime,
825
- rename: 0,
826
- token: percentEncode(creds.token),
827
- plat: "pc",
828
- nasid: creds.nasId,
829
- version: creds.appVersion,
830
- device_id: creds.deviceId,
831
- device: percentEncode(creds.device),
832
- "request-purpose": 4,
833
- "remote-port": 8050,
834
- split: 1,
835
- };
836
- const headers = Object.fromEntries(Object.entries(params).map(([key, value]) => [key, String(value)]));
837
- headers.Cookie = Object.entries(params)
838
- .map(([key, value]) => `${key === "nasid" ? "nas_id" : key}=${percentEncode(value)}`)
839
- .join("; ");
840
- headers["Content-Type"] = "application/octet-stream";
841
- headers["Content-Length"] = String(length);
842
- const url = `${this.baseUrl}/v2/file/upload?remote_port=8050&drnd=${Date.now()}&uuid=${uuid}`;
843
- await this.retrying(
844
- async () => {
845
- const response = await this.send({ method: "POST", url, headers, body: buffer });
846
- this.parse(response, "/v2/file/upload");
847
- },
848
- error => !(error instanceof ZSpaceError && UPLOAD_FATAL_CODES.has(error.code)),
849
- );
850
- sent += length;
851
- onProgress?.(sent, total);
852
- }
853
- } finally {
854
- await handle.close();
855
- }
176
+ async retrying(attempt, shouldRetry) {
177
+ return await retrying(this, attempt, shouldRetry);
178
+ }
179
+
180
+ async send(options) {
181
+ return await send(this, options);
182
+ }
183
+
184
+ parse(response, endpoint) {
185
+ return parse(this, response, endpoint);
186
+ }
187
+
188
+ async post(endpoint, fields) {
189
+ return await post(this, endpoint, fields);
856
190
  }
857
191
  }