dsh-jiuling-remote-mobile 1.0.13 → 1.0.15
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/index.js +171 -12
- 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,177 @@ function saveRemoteSessions(set, logger) {
|
|
|
214
216
|
*/
|
|
215
217
|
async function remoteFetch(url, init) {
|
|
216
218
|
const pool = await remotePool();
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
219
|
+
// ① 首选:undici 专用池(关掉 autoSelectFamily,Android 上最稳)。
|
|
220
|
+
// ② 备用:`node:https`(强制 IPv4,不依赖 undici 解析)。
|
|
221
|
+
//
|
|
222
|
+
// ⚠️ **两条通道必须互为备份,不能只做「解析失败才兜底」**。线上实测过:
|
|
223
|
+
// 同一个版本、同一个账号,配对服务器日志里既有 `POST /pair/claim 400`
|
|
224
|
+
// (请求到了)又有界面上的 `fetch failed`(请求没到)—— 说明是**间歇性**
|
|
225
|
+
// 建连失败。只修「undici 没解析到」那一种情形,另一种照样挂。
|
|
226
|
+
//
|
|
227
|
+
// 重试是安全的:`fetch()` 抛错意味着**建连或响应头阶段**就失败了,
|
|
228
|
+
// 此时响应体一个字节都还没消费,重发不会产生重复副作用。
|
|
229
|
+
// (流中途断开不会走到这里 —— 那是 `for await` 里的事,不在本函数内。)
|
|
230
|
+
const attempts = [];
|
|
231
|
+
if (pool !== null) {
|
|
232
|
+
try {
|
|
233
|
+
return await pool.undici.fetch(url, { ...init, dispatcher: pool.agent });
|
|
234
|
+
} catch (error) {
|
|
235
|
+
if (isAbort(error)) throw error;
|
|
236
|
+
attempts.push(describeFetchError(error, '走的是专用 undici 池'));
|
|
237
|
+
}
|
|
238
|
+
} else if (!remoteFallbackWarned) {
|
|
239
|
+
remoteFallbackWarned = true;
|
|
240
|
+
console.warn('[dsh-remote-mobile] 拿不到 undici,改用 node:https 通道(强制 IPv4)。');
|
|
241
|
+
}
|
|
242
|
+
try {
|
|
243
|
+
return await nodeHttpsFetch(url, init);
|
|
244
|
+
} catch (error) {
|
|
245
|
+
if (isAbort(error)) throw error;
|
|
246
|
+
attempts.push(describeFetchError(error, '走的是 node:https 通道(强制 IPv4)'));
|
|
247
|
+
throw new Error(`两条通道都失败:\n ① ${attempts[0]}\n ② ${attempts[1]}`);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* 是否是**主动中止**(不是网络故障)。
|
|
253
|
+
*
|
|
254
|
+
* ⚠️ SSE 断开重连靠 `AbortController`,把它当成网络故障包装掉,重连逻辑就失效了。
|
|
255
|
+
*
|
|
256
|
+
* @param error - 抛出的错误。
|
|
257
|
+
* @returns 是否应当原样上抛。
|
|
258
|
+
*/
|
|
259
|
+
function isAbort(error) {
|
|
260
|
+
return error?.name === 'AbortError' || error?.name === 'TimeoutError';
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* 不依赖 undici 的 HTTPS 兜底通道(只用 Node 内置的 `node:https`)。
|
|
265
|
+
*
|
|
266
|
+
* ## 为什么需要它
|
|
267
|
+
*
|
|
268
|
+
* `undici` 是**引擎**的依赖(`<engine>/node_modules/undici`),不在插件的解析链上。
|
|
269
|
+
* 一旦解析不到,退回全局 `fetch` 在 Android 上会因 **Happy Eyeballs** 卡死
|
|
270
|
+
* (真机实测 12018 ms 超时,而关掉 `autoSelectFamily` 的池 2658 ms 就通)。
|
|
271
|
+
* 症状是配对时一句 `fetch failed`,配对服务器日志里**根本没有这次请求**。
|
|
272
|
+
*
|
|
273
|
+
* `node:https` 是 Node 内置模块,**解析零风险**,且它天然按 DNS 返回顺序逐个尝试
|
|
274
|
+
* 地址(不做 Happy Eyeballs 竞速),实测在真机上 2125 ms 就通。
|
|
275
|
+
*
|
|
276
|
+
* 这里刻意**只强制 IPv4**:Android 上运营商给 AAAA 记录、但 IPv6 实际不通是常态,
|
|
277
|
+
* 而 `node:https` 遇到连不通的地址要等超时才换下一个 —— 直接锁 IPv4 最稳。
|
|
278
|
+
*
|
|
279
|
+
* ## 能力取舍(调用方按需使用)
|
|
280
|
+
*
|
|
281
|
+
* 只实现本插件真正用到的部分:POST/GET + 自定义头 + 文本/流式响应体。
|
|
282
|
+
* 返回对象形状对齐 `fetch`:`{ ok, status, headers: {get}, text(), json(), body }`,
|
|
283
|
+
* 其中 `body` 是**异步可迭代**的(SSE 消费方 `for await (const chunk of res.body)` 照常工作)。
|
|
284
|
+
*
|
|
285
|
+
* @param url - 完整 URL(https 走 TLS,http 走明文,便于本机调试)。
|
|
286
|
+
* @param init - `{ method, headers, body, signal }`。
|
|
287
|
+
* @returns 类 `fetch` 响应对象。
|
|
288
|
+
*/
|
|
289
|
+
function nodeHttpsFetch(url, init = {}) {
|
|
290
|
+
const target = new URL(url);
|
|
291
|
+
const isTls = target.protocol === 'https:';
|
|
292
|
+
const send = isTls ? httpsRequest : httpRequest;
|
|
293
|
+
const method = init.method ?? 'GET';
|
|
294
|
+
const headers = { ...(init.headers ?? {}) };
|
|
295
|
+
// 强制 IPv4:见上面的注释(IPv6 不通时能省掉一整个超时窗口)。
|
|
296
|
+
const options = {
|
|
297
|
+
protocol: target.protocol,
|
|
298
|
+
hostname: target.hostname,
|
|
299
|
+
port: target.port === '' ? (isTls ? 443 : 80) : target.port,
|
|
300
|
+
path: `${target.pathname}${target.search}`,
|
|
301
|
+
method,
|
|
302
|
+
headers,
|
|
303
|
+
family: 4,
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
return new Promise((resolve, reject) => {
|
|
307
|
+
const req = send(options, (res) => {
|
|
308
|
+
const body = res[Symbol.asyncIterator]
|
|
309
|
+
? res
|
|
310
|
+
: (async function* empty() {})();
|
|
311
|
+
resolve({
|
|
312
|
+
ok: res.statusCode >= 200 && res.statusCode < 300,
|
|
313
|
+
status: res.statusCode,
|
|
314
|
+
headers: {
|
|
315
|
+
get: (name) => res.headers[String(name).toLowerCase()] ?? null,
|
|
316
|
+
},
|
|
317
|
+
body,
|
|
318
|
+
text: () =>
|
|
319
|
+
new Promise((done, fail) => {
|
|
320
|
+
const chunks = [];
|
|
321
|
+
res.on('data', (chunk) => chunks.push(chunk));
|
|
322
|
+
res.on('end', () => done(Buffer.concat(chunks).toString('utf8')));
|
|
323
|
+
res.on('error', fail);
|
|
324
|
+
}),
|
|
325
|
+
json() {
|
|
326
|
+
return this.text().then((text) => JSON.parse(text));
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
req.on('error', reject);
|
|
331
|
+
if (init.signal) {
|
|
332
|
+
if (init.signal.aborted) {
|
|
333
|
+
req.destroy();
|
|
334
|
+
const abort = new Error('The operation was aborted');
|
|
335
|
+
abort.name = 'AbortError';
|
|
336
|
+
reject(abort);
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
init.signal.addEventListener('abort', () => {
|
|
340
|
+
req.destroy();
|
|
341
|
+
const abort = new Error('The operation was aborted');
|
|
342
|
+
abort.name = 'AbortError';
|
|
343
|
+
reject(abort);
|
|
344
|
+
});
|
|
227
345
|
}
|
|
228
|
-
|
|
346
|
+
if (init.body !== undefined && init.body !== null) req.write(init.body);
|
|
347
|
+
req.end();
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* 把 fetch 的失败原因展开成可读文字。
|
|
353
|
+
*
|
|
354
|
+
* ⚠️ **`fetch failed` 本身不含任何可诊断信息** —— 真正的 errno 藏在 `error.cause`
|
|
355
|
+
* (有时还要再深一层 `cause.cause`)里。不展开的话,界面上只能看到「fetch failed」,
|
|
356
|
+
* 而下面这些完全不同的故障会给出**一模一样**的文案,导致无法判断该改代码还是换通道:
|
|
357
|
+
*
|
|
358
|
+
* | 真因(在 cause 里) | 含义 | 对策 |
|
|
359
|
+
* |---|---|---|
|
|
360
|
+
* | `ENOTFOUND` | DNS 解析不了(运营商 DNS 污染/无网络) | 换 DNS 或换通道 |
|
|
361
|
+
* | `ETIMEDOUT` / `UND_ERR_CONNECT_TIMEOUT` | 连得上 DNS、连不上 IP(被阻断) | 换通道 |
|
|
362
|
+
* | `ECONNRESET` / `UND_ERR_SOCKET` | 建连后被重置(中间设备干扰 TLS) | 换通道 |
|
|
363
|
+
* | `CERT_*` | 证书校验失败(手机时间不对也会这样) | 校时 |
|
|
364
|
+
*
|
|
365
|
+
* @param error - `fetch` 抛出的错误。
|
|
366
|
+
* @param channel - 这条错误来自哪条通道(`'undici'` / `'https'`)。
|
|
367
|
+
* @returns 形如 `fetch failed ← ENOTFOUND getaddrinfo ENOTFOUND pair… ← 走的是专用 undici 池`。
|
|
368
|
+
*/
|
|
369
|
+
function describeFetchError(error, channel) {
|
|
370
|
+
const parts = [];
|
|
371
|
+
if (error?.message) parts.push(String(error.message));
|
|
372
|
+
let cause = error?.cause;
|
|
373
|
+
for (let depth = 0; cause && depth < 3; depth += 1) {
|
|
374
|
+
const code = cause.code ? String(cause.code) : '';
|
|
375
|
+
const message = cause.message ? String(cause.message) : '';
|
|
376
|
+
const text = code !== '' && message !== '' ? `${code} ${message}` : code || message;
|
|
377
|
+
if (text !== '') parts.push(text);
|
|
378
|
+
cause = cause.cause;
|
|
379
|
+
}
|
|
380
|
+
// 同时连多个地址时抛的是 AggregateError,每个子错误的 errno 都要看到。
|
|
381
|
+
if (Array.isArray(error?.errors) && error.errors.length > 0) {
|
|
382
|
+
parts.push(
|
|
383
|
+
error.errors
|
|
384
|
+
.map((item) => `${item?.code ?? ''} ${item?.message ?? ''}`.trim())
|
|
385
|
+
.join(' | '),
|
|
386
|
+
);
|
|
229
387
|
}
|
|
230
|
-
|
|
388
|
+
parts.push(channel);
|
|
389
|
+
return parts.join(' ← ');
|
|
231
390
|
}
|
|
232
391
|
|
|
233
392
|
/** 解析 undici 失败时的告警只打一次(否则每条 RPC 刷一行)。 */
|