dsh-jiuling-remote-mobile 1.0.12 → 1.0.14

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.
Files changed (2) hide show
  1. package/lib/index.js +219 -9
  2. package/package.json +1 -1
package/lib/index.js CHANGED
@@ -49,6 +49,8 @@
49
49
 
50
50
  import { createHash, randomUUID } from 'node:crypto';
51
51
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
52
+ import { request as httpRequest } from 'node:http';
53
+ import { request as httpsRequest } from 'node:https';
52
54
  import { homedir } from 'node:os';
53
55
  import { join } from 'node:path';
54
56
 
@@ -208,21 +210,229 @@ function saveRemoteSessions(set, logger) {
208
210
  * 所以本插件**不复用全局 dispatcher**,自己建一个关掉 `autoSelectFamily` 的池。
209
211
  * 只影响本插件发出的请求,不碰进程全局状态。
210
212
  *
211
- * @returns `{ dispatcher, fetch }`;拿不到 undici 时 `dispatcher` 为 `undefined`。
213
+ * @param url - 完整 URL。
214
+ * @param init - `fetch` 的第二参数。
215
+ * @returns 响应。
212
216
  */
213
- let remoteAgent;
214
217
  async function remoteFetch(url, init) {
215
- if (remoteAgent === undefined) {
218
+ const pool = await remotePool();
219
+ try {
220
+ if (pool === null) {
221
+ // 拿不到 undici ⇒ 走自带的 node:https 兜底(见 nodeHttpsFetch)。
222
+ // ⚠️ **不要退回全局 fetch**:它在 Android 上会因 Happy Eyeballs 卡死,
223
+ // 且失败时只给一句 `fetch failed`(真因藏在 cause 里),实测导致
224
+ // 「一台手机能配对、另一台报 fetch failed」这种无从下手的现象。
225
+ if (!remoteFallbackWarned) {
226
+ remoteFallbackWarned = true;
227
+ console.warn(
228
+ '[dsh-remote-mobile] 拿不到 undici,改用 node:https 兜底通道(强制 IPv4)。',
229
+ );
230
+ }
231
+ return await nodeHttpsFetch(url, init);
232
+ }
233
+ return await pool.undici.fetch(url, { ...init, dispatcher: pool.agent });
234
+ } catch (error) {
235
+ // 主动中止(SSE 断开重连)不是故障,原样抛给调用方,否则重连逻辑会失效。
236
+ if (error?.name === 'AbortError' || error?.name === 'TimeoutError') throw error;
237
+ throw new Error(describeFetchError(error, pool === null));
238
+ }
239
+ }
240
+
241
+ /**
242
+ * 不依赖 undici 的 HTTPS 兜底通道(只用 Node 内置的 `node:https`)。
243
+ *
244
+ * ## 为什么需要它
245
+ *
246
+ * `undici` 是**引擎**的依赖(`<engine>/node_modules/undici`),不在插件的解析链上。
247
+ * 一旦解析不到,退回全局 `fetch` 在 Android 上会因 **Happy Eyeballs** 卡死
248
+ * (真机实测 12018 ms 超时,而关掉 `autoSelectFamily` 的池 2658 ms 就通)。
249
+ * 症状是配对时一句 `fetch failed`,配对服务器日志里**根本没有这次请求**。
250
+ *
251
+ * `node:https` 是 Node 内置模块,**解析零风险**,且它天然按 DNS 返回顺序逐个尝试
252
+ * 地址(不做 Happy Eyeballs 竞速),实测在真机上 2125 ms 就通。
253
+ *
254
+ * 这里刻意**只强制 IPv4**:Android 上运营商给 AAAA 记录、但 IPv6 实际不通是常态,
255
+ * 而 `node:https` 遇到连不通的地址要等超时才换下一个 —— 直接锁 IPv4 最稳。
256
+ *
257
+ * ## 能力取舍(调用方按需使用)
258
+ *
259
+ * 只实现本插件真正用到的部分:POST/GET + 自定义头 + 文本/流式响应体。
260
+ * 返回对象形状对齐 `fetch`:`{ ok, status, headers: {get}, text(), json(), body }`,
261
+ * 其中 `body` 是**异步可迭代**的(SSE 消费方 `for await (const chunk of res.body)` 照常工作)。
262
+ *
263
+ * @param url - 完整 URL(https 走 TLS,http 走明文,便于本机调试)。
264
+ * @param init - `{ method, headers, body, signal }`。
265
+ * @returns 类 `fetch` 响应对象。
266
+ */
267
+ function nodeHttpsFetch(url, init = {}) {
268
+ const target = new URL(url);
269
+ const isTls = target.protocol === 'https:';
270
+ const send = isTls ? httpsRequest : httpRequest;
271
+ const method = init.method ?? 'GET';
272
+ const headers = { ...(init.headers ?? {}) };
273
+ // 强制 IPv4:见上面的注释(IPv6 不通时能省掉一整个超时窗口)。
274
+ const options = {
275
+ protocol: target.protocol,
276
+ hostname: target.hostname,
277
+ port: target.port === '' ? (isTls ? 443 : 80) : target.port,
278
+ path: `${target.pathname}${target.search}`,
279
+ method,
280
+ headers,
281
+ family: 4,
282
+ };
283
+
284
+ return new Promise((resolve, reject) => {
285
+ const req = send(options, (res) => {
286
+ const body = res[Symbol.asyncIterator]
287
+ ? res
288
+ : (async function* empty() {})();
289
+ resolve({
290
+ ok: res.statusCode >= 200 && res.statusCode < 300,
291
+ status: res.statusCode,
292
+ headers: {
293
+ get: (name) => res.headers[String(name).toLowerCase()] ?? null,
294
+ },
295
+ body,
296
+ text: () =>
297
+ new Promise((done, fail) => {
298
+ const chunks = [];
299
+ res.on('data', (chunk) => chunks.push(chunk));
300
+ res.on('end', () => done(Buffer.concat(chunks).toString('utf8')));
301
+ res.on('error', fail);
302
+ }),
303
+ json() {
304
+ return this.text().then((text) => JSON.parse(text));
305
+ },
306
+ });
307
+ });
308
+ req.on('error', reject);
309
+ if (init.signal) {
310
+ if (init.signal.aborted) {
311
+ req.destroy();
312
+ const abort = new Error('The operation was aborted');
313
+ abort.name = 'AbortError';
314
+ reject(abort);
315
+ return;
316
+ }
317
+ init.signal.addEventListener('abort', () => {
318
+ req.destroy();
319
+ const abort = new Error('The operation was aborted');
320
+ abort.name = 'AbortError';
321
+ reject(abort);
322
+ });
323
+ }
324
+ if (init.body !== undefined && init.body !== null) req.write(init.body);
325
+ req.end();
326
+ });
327
+ }
328
+
329
+ /**
330
+ * 把 fetch 的失败原因展开成可读文字。
331
+ *
332
+ * ⚠️ **`fetch failed` 本身不含任何可诊断信息** —— 真正的 errno 藏在 `error.cause`
333
+ * (有时还要再深一层 `cause.cause`)里。不展开的话,界面上只能看到「fetch failed」,
334
+ * 而下面这些完全不同的故障会给出**一模一样**的文案,导致无法判断该改代码还是换通道:
335
+ *
336
+ * | 真因(在 cause 里) | 含义 | 对策 |
337
+ * |---|---|---|
338
+ * | `ENOTFOUND` | DNS 解析不了(运营商 DNS 污染/无网络) | 换 DNS 或换通道 |
339
+ * | `ETIMEDOUT` / `UND_ERR_CONNECT_TIMEOUT` | 连得上 DNS、连不上 IP(被阻断) | 换通道 |
340
+ * | `ECONNRESET` / `UND_ERR_SOCKET` | 建连后被重置(中间设备干扰 TLS) | 换通道 |
341
+ * | `CERT_*` | 证书校验失败(手机时间不对也会这样) | 校时 |
342
+ *
343
+ * @param error - `fetch` 抛出的错误。
344
+ * @param usedGlobalFetch - 是否走了降级路径(用于区分「网络问题」与「undici 没加载到」)。
345
+ * @returns 形如 `fetch failed ← ENOTFOUND getaddrinfo ENOTFOUND pair… ← 走的是专用 undici 池`。
346
+ */
347
+ function describeFetchError(error, usedGlobalFetch) {
348
+ const parts = [];
349
+ if (error?.message) parts.push(String(error.message));
350
+ let cause = error?.cause;
351
+ for (let depth = 0; cause && depth < 3; depth += 1) {
352
+ const code = cause.code ? String(cause.code) : '';
353
+ const message = cause.message ? String(cause.message) : '';
354
+ const text = code !== '' && message !== '' ? `${code} ${message}` : code || message;
355
+ if (text !== '') parts.push(text);
356
+ cause = cause.cause;
357
+ }
358
+ // 同时连多个地址时抛的是 AggregateError,每个子错误的 errno 都要看到。
359
+ if (Array.isArray(error?.errors) && error.errors.length > 0) {
360
+ parts.push(
361
+ error.errors
362
+ .map((item) => `${item?.code ?? ''} ${item?.message ?? ''}`.trim())
363
+ .join(' | '),
364
+ );
365
+ }
366
+ parts.push(usedGlobalFetch ? '走的是全局 fetch(undici 没加载到)' : '走的是专用 undici 池');
367
+ return parts.join(' ← ');
368
+ }
369
+
370
+ /** 解析 undici 失败时的告警只打一次(否则每条 RPC 刷一行)。 */
371
+ let remoteFallbackWarned = false;
372
+
373
+ /** 专用连接池的初始化 Promise(并发首调只初始化一次)。 */
374
+ let remotePoolPromise;
375
+
376
+ /**
377
+ * 建(或取)专用连接池。
378
+ *
379
+ * @returns `{ undici, agent }`;拿不到 undici 时 `null`。
380
+ */
381
+ function remotePool() {
382
+ if (remotePoolPromise === undefined) {
383
+ remotePoolPromise = (async () => {
384
+ const undici = await loadUndici();
385
+ if (undici === null) return null;
386
+ return { undici, agent: new undici.Agent({ connect: { autoSelectFamily: false } }) };
387
+ })();
388
+ }
389
+ return remotePoolPromise;
390
+ }
391
+
392
+ /**
393
+ * 解析 undici 模块。
394
+ *
395
+ * ## ⚠️ 为什么不能只写 `await import('undici')`(配对报 `fetch failed` 的根因)
396
+ *
397
+ * 本插件装在 `<DSH_HOME>/profiles/<profile>/node_modules/`,而 undici 是
398
+ * **引擎**的依赖,装在 `<engine>/node_modules/undici` —— 它在插件的解析链
399
+ * **之外**,于是 `import('undici')` 抛 `ERR_MODULE_NOT_FOUND`,`remoteFetch`
400
+ * 静默退回全局 `fetch`(即上面那张表里 12018 ms 超时的那个)。
401
+ *
402
+ * 真机实测(CDY_TN90 / Android 10 / 插件 1.0.12,2026-10-16):
403
+ *
404
+ * | 写法 | 结果 |
405
+ * |---|---|
406
+ * | `await import('undici')`(插件目录内) | **ERR_MODULE_NOT_FOUND** ❌ |
407
+ * | `createRequire(process.argv[1]).resolve('undici')` | ✅ → `<engine>/node_modules/undici/index.js` |
408
+ *
409
+ * 这个故障是**间歇性**的:网络恰好在 Happy Eyeballs 回退窗口内时全局 fetch
410
+ * 也能通,所以会看到「同一个版本、一台手机能配对、另一台报 fetch failed」——
411
+ * 而**配对服务器日志里根本没有那次请求**(请求没出网),这是最快的判据。
412
+ *
413
+ * @returns undici 模块;都失败时 `null`。
414
+ */
415
+ async function loadUndici() {
416
+ // ① 首选:以**引擎入口**为锚点解析。插件由引擎加载,`process.argv[1]` 就是
417
+ // 引擎的 bin.js(实测 …/engine/node_modules/@deepseek-ai/dsh/lib/bin.js),
418
+ // 它的 node_modules 里就有 undici。
419
+ const entry = process.argv[1];
420
+ if (typeof entry === 'string' && entry !== '') {
216
421
  try {
217
- const undici = await import('undici');
218
- remoteAgent = new undici.Agent({ connect: { autoSelectFamily: false } });
422
+ const { createRequire } = await import('node:module');
423
+ const { pathToFileURL } = await import('node:url');
424
+ const resolved = createRequire(pathToFileURL(entry).href).resolve('undici');
425
+ return await import(pathToFileURL(resolved).href);
219
426
  } catch {
220
- remoteAgent = null; // 没有 undici:退回全局 fetch
427
+ /* 换下一个锚点 */
221
428
  }
222
429
  }
223
- if (remoteAgent === null || remoteAgent === undefined) return fetch(url, init);
224
- const undici = await import('undici');
225
- return undici.fetch(url, { ...init, dispatcher: remoteAgent });
430
+ // ② 兜底:本插件自己的解析链(将来若把 undici 声明成依赖,这里会命中)。
431
+ try {
432
+ return await import('undici');
433
+ } catch {
434
+ return null;
435
+ }
226
436
  }
227
437
 
228
438
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-jiuling-remote-mobile",
3
- "version": "1.0.12",
3
+ "version": "1.0.14",
4
4
  "description": "DSH 远程目录(手机端):在选文件夹流程里多出「电脑」页签,进入电脑目录、在电脑上发起对话,并把电脑的实时消息流同步到手机本地界面(只传文本,不映射桌面)。",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",