dsh-pocket 1.1.0 → 1.2.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/lib/tunnel.mjs +107 -3
- package/package.json +1 -1
package/lib/tunnel.mjs
CHANGED
|
@@ -36,10 +36,114 @@ const CLOUDFLARED_MIRRORS = [
|
|
|
36
36
|
|
|
37
37
|
const TUNA_BOTTLES = 'https://mirrors.tuna.tsinghua.edu.cn/homebrew-bottles/';
|
|
38
38
|
|
|
39
|
+
/** 多线程分块下载的并发段数(Windows 官方源单线程 ~200KB/s,8 并发 ≈ 1.6MB/s)。 */
|
|
40
|
+
const PARALLEL_SEGMENTS = 8;
|
|
41
|
+
/** 小于该字节数的文件不值得分块(直接用单线程)。 */
|
|
42
|
+
const MIN_PARALLEL_SIZE = 8 * 1024 * 1024;
|
|
43
|
+
/** 探针大小:单线程先下这么多测速。 */
|
|
44
|
+
const PROBE_SIZE = 2 * 1024 * 1024;
|
|
45
|
+
/** 探针测速阈值(bytes/ms):低于它认为慢网络,切多线程。300KB/s = 0.3。 */
|
|
46
|
+
const SLOW_SPEED_THRESHOLD = 0.3;
|
|
47
|
+
|
|
39
48
|
function hostOf(url) {
|
|
40
49
|
try { return new URL(url).host; } catch { return url; }
|
|
41
50
|
}
|
|
42
51
|
|
|
52
|
+
/** 合并多个分段文件为一个目标文件(顺序拼接后统一结束)。 */
|
|
53
|
+
async function mergeParts(partFiles, dest) {
|
|
54
|
+
const { createReadStream } = await import('node:fs');
|
|
55
|
+
const out = createWriteStream(dest);
|
|
56
|
+
try {
|
|
57
|
+
for (const f of partFiles) {
|
|
58
|
+
await new Promise((resolve, reject) => {
|
|
59
|
+
const rs = createReadStream(f);
|
|
60
|
+
rs.on('error', reject);
|
|
61
|
+
rs.pipe(out, { end: false });
|
|
62
|
+
rs.on('end', resolve);
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
} finally {
|
|
66
|
+
await new Promise((r) => out.end(r));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 下载文件到 dest(自适应):
|
|
72
|
+
* 1. 服务器不支持 Range 或文件小 → 单线程;
|
|
73
|
+
* 2. 单线程下载探针(PROBE_SIZE)测速——速度够快 → 继续单线程(多线程在部分网络/
|
|
74
|
+
* 服务器上反而更慢,如 GitHub CDN 并发限速);
|
|
75
|
+
* 3. 探针速度低于阈值(典型慢网络,如 Windows 用户官方源 ~200KB/s)→ 丢弃探针,
|
|
76
|
+
* 改 8 段并发分块(可把 200KB/s 拉到 1.6MB/s)。
|
|
77
|
+
* 返回实际下载字节数。
|
|
78
|
+
*/
|
|
79
|
+
export async function downloadFile(url, dest, { signal, segments = PARALLEL_SEGMENTS } = {}) {
|
|
80
|
+
// HEAD 探测:Content-Length + Accept-Ranges
|
|
81
|
+
let head = null;
|
|
82
|
+
try { head = await fetch(url, { method: 'HEAD', signal }); } catch { head = null; }
|
|
83
|
+
const len = head ? Number(head.headers.get('content-length') || 0) : 0;
|
|
84
|
+
const acceptsRanges = head ? String(head.headers.get('accept-ranges') || '').toLowerCase() === 'bytes' : false;
|
|
85
|
+
|
|
86
|
+
if (!head || !acceptsRanges || len < MIN_PARALLEL_SIZE) {
|
|
87
|
+
// 单线程
|
|
88
|
+
const res = await fetch(url, { signal });
|
|
89
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
90
|
+
await pipeline(Readable.fromWeb(res.body), createWriteStream(dest));
|
|
91
|
+
return len || 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 探针测速:单线程下载前 PROBE_SIZE,计时
|
|
95
|
+
const probeBytes = Math.min(PROBE_SIZE, len);
|
|
96
|
+
const probeStart = Date.now();
|
|
97
|
+
try {
|
|
98
|
+
const probeRes = await fetch(url, { signal, headers: { Range: `bytes=0-${probeBytes - 1}` } });
|
|
99
|
+
if (!probeRes.ok) throw new Error(`HTTP ${probeRes.status} (probe)`);
|
|
100
|
+
const probeBody = await probeRes.arrayBuffer();
|
|
101
|
+
const probeMs = Date.now() - probeStart;
|
|
102
|
+
const probeSpeed = probeMs > 0 ? probeBytes / probeMs : Infinity; // bytes/ms
|
|
103
|
+
if (probeMs < 500 || probeSpeed >= SLOW_SPEED_THRESHOLD) {
|
|
104
|
+
// 够快 → 单线程下完剩余部分(探针字节已拿到,写入 dest)
|
|
105
|
+
const { createWriteStream, createReadStream } = await import('node:fs');
|
|
106
|
+
const w = createWriteStream(dest);
|
|
107
|
+
await new Promise((resolve, reject) => {
|
|
108
|
+
w.on('error', reject);
|
|
109
|
+
w.write(Buffer.from(probeBody));
|
|
110
|
+
w.end(resolve);
|
|
111
|
+
});
|
|
112
|
+
const restRes = await fetch(url, { signal, headers: { Range: `bytes=${probeBytes}-${len - 1}` } });
|
|
113
|
+
if (!restRes.ok) throw new Error(`HTTP ${restRes.status} (rest)`);
|
|
114
|
+
await pipeline(Readable.fromWeb(restRes.body), createWriteStream(dest, { flags: 'a' }));
|
|
115
|
+
return len;
|
|
116
|
+
}
|
|
117
|
+
// 慢 → 丢弃探针,转分块并发(从 0 开始全量分块)
|
|
118
|
+
await rm(dest, { force: true }).catch(() => {});
|
|
119
|
+
} catch (err) {
|
|
120
|
+
await rm(dest, { force: true }).catch(() => {});
|
|
121
|
+
if (!/HTTP|fetch/i.test(String(err?.message ?? ''))) throw err; // 探针网络异常 → 抛给上层换源
|
|
122
|
+
// 探针 HTTP 错误(部分服务器 HEAD 与 GET 行为不一致)→ 直接分块
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// 分块并发
|
|
126
|
+
const parts = [];
|
|
127
|
+
const chunk = Math.ceil(len / segments);
|
|
128
|
+
for (let i = 0; i < segments; i++) {
|
|
129
|
+
const start = i * chunk;
|
|
130
|
+
const end = i === segments - 1 ? len - 1 : Math.min(start + chunk - 1, len - 1);
|
|
131
|
+
if (start > end) break;
|
|
132
|
+
parts.push({ start, end, file: `${dest}.part${i}` });
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
await Promise.all(parts.map(async (p) => {
|
|
136
|
+
const res = await fetch(url, { signal, headers: { Range: `bytes=${p.start}-${p.end}` } });
|
|
137
|
+
if (!res.ok) throw new Error(`HTTP ${res.status} (range ${p.start}-${p.end})`);
|
|
138
|
+
await pipeline(Readable.fromWeb(res.body), createWriteStream(p.file));
|
|
139
|
+
}));
|
|
140
|
+
await mergeParts(parts.map((p) => p.file), dest);
|
|
141
|
+
} finally {
|
|
142
|
+
await Promise.all(parts.map((p) => rm(p.file, { force: true }).catch(() => {})));
|
|
143
|
+
}
|
|
144
|
+
return len;
|
|
145
|
+
}
|
|
146
|
+
|
|
43
147
|
/**
|
|
44
148
|
* 清华 TUNA 镜像的 cloudflared Homebrew bottle URL(国内 CDN,实测 ~3MB/s)。
|
|
45
149
|
* 只覆盖 macOS/Linux(Windows 无 Homebrew bottle,返回 null)。
|
|
@@ -93,9 +197,9 @@ async function downloadCloudflared(binPath, signal) {
|
|
|
93
197
|
const { url, host } = sources[i];
|
|
94
198
|
console.log(`⬇️ 下载 cloudflared(${i + 1}/${sources.length}:${host})…`);
|
|
95
199
|
try {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
await
|
|
200
|
+
// 多线程分块(官方 GitHub 支持 Range,Windows 50MB 从几分钟降到几十秒);
|
|
201
|
+
// 不支持 Range 的源自动回退单线程
|
|
202
|
+
await downloadFile(url, tmpFile, { signal: fetchSignal });
|
|
99
203
|
// 简单校验:空文件/极小文件视为下载失败(可能是镜像返回了错误页)
|
|
100
204
|
const st = await stat(tmpFile);
|
|
101
205
|
if (st.size < 1024 * 1024) throw new Error(`文件异常小(${st.size} 字节),疑似镜像错误页`);
|
package/package.json
CHANGED