dsh-jiuling-remote-mobile 1.0.13 → 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 +149 -12
  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
 
@@ -214,20 +216,155 @@ function saveRemoteSessions(set, logger) {
214
216
  */
215
217
  async function remoteFetch(url, init) {
216
218
  const pool = await remotePool();
217
- if (pool === null) {
218
- // 退到全局 fetch 是**故障态**,不是正常路径 —— 必须留下痕迹,
219
- // 否则又变成上面那种「看不出根因」的间歇性失败。
220
- if (!remoteFallbackWarned) {
221
- remoteFallbackWarned = true;
222
- console.warn(
223
- '[dsh-remote-mobile] 拿不到 undici,已退回全局 fetch:Android 上可能因 ' +
224
- 'Happy Eyeballs 卡死(见 remoteFetch 注释)。配对/远程调用若报 ' +
225
- '"fetch failed",根因在这里。',
226
- );
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
+ });
227
323
  }
228
- return fetch(url, init);
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
+ );
229
365
  }
230
- return pool.undici.fetch(url, { ...init, dispatcher: pool.agent });
366
+ parts.push(usedGlobalFetch ? '走的是全局 fetch(undici 没加载到)' : '走的是专用 undici 池');
367
+ return parts.join(' ← ');
231
368
  }
232
369
 
233
370
  /** 解析 undici 失败时的告警只打一次(否则每条 RPC 刷一行)。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-jiuling-remote-mobile",
3
- "version": "1.0.13",
3
+ "version": "1.0.14",
4
4
  "description": "DSH 远程目录(手机端):在选文件夹流程里多出「电脑」页签,进入电脑目录、在电脑上发起对话,并把电脑的实时消息流同步到手机本地界面(只传文本,不映射桌面)。",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",