reply9842 0.2.1 → 0.3.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/README.md CHANGED
@@ -11,6 +11,9 @@ Agent 每请求要重传 1.3–2.2 MB 的完整上下文,日请求 2 万+,
11
11
  ```
12
12
 
13
13
  > 数据来自生产环境连续观测,含推理型模型的完整上下文。见[实测](#实测)。
14
+ >
15
+ > 它对协议本身不做任何假设:只是一个 HTTP 正向转发,任何「请求体大且相邻请求高度相似」
16
+ > 的流量都适用。不绑定任何服务商,也不需要客户端侧改协议。
14
17
 
15
18
  ---
16
19
 
@@ -128,9 +131,9 @@ QUIC 与 TCP/TLS 共用同一个端口。
128
131
  ### 2. 客户端(和 Agent 同机)
129
132
 
130
133
  ```bash
131
- go build -o relay-client ./cmd/relay-client
134
+ go build -o relay-cli ./cmd/cli
132
135
 
133
- ./relay-client \
136
+ ./relay-cli \
134
137
  -listen 127.0.0.1:18899 \
135
138
  -server 47.82.76.102:28443 \
136
139
  -server-name magpie.aiweb.xin \
@@ -139,13 +142,35 @@ go build -o relay-client ./cmd/relay-client
139
142
 
140
143
  然后把 Agent 的 `base_url` 指向 `http://127.0.0.1:18899`。其余不变。
141
144
 
145
+ > **用 IP 直连时必须给 `-server-name`。** 服务端证书的 SAN 只有 DNS 没有 IP,
146
+ > 留空会按 `-server` 的 host(那个 IP)推导 SNI,必然 `x509: cannot validate
147
+ > certificate ... doesn't contain any IP SANs`。用域名连则不需要显式指定。
148
+
149
+ #### 连不上?先怀疑本机代理
150
+
151
+ 如果本机开着代理的 **TUN / 增强模式**(Clash 系常见),它用
152
+ `0.0.0.0/1` + `128.0.0.0/1` 两条路由把整个 IPv4 空间导进 utun,隧道流量也被吞进去;
153
+ 代理对 `28443` 这类非标端口没有正确规则,TLS 握手会被中途切断,表现为
154
+ `tcp tls: EOF` 或 `quic ... no application protocol`——看起来像服务端崩了。
155
+
156
+ 两条解法,任选其一:
157
+
158
+ 1. 在代理里把服务器 IP 加进**直连规则**(推荐)
159
+ 2. 加 `-local-addr <本机网卡 IP>` 绑定出站源地址,让内核直接走物理网卡:
160
+
161
+ ```bash
162
+ ipconfig getifaddr en0 # macOS 取网卡 IP
163
+ ./relay-cli -local-addr 192.168.1.106 ...
164
+ ```
165
+
142
166
  ### 客户端参数
143
167
 
144
168
  | 参数 | 默认 | 说明 |
145
169
  |---|---|---|
146
- | `-listen` | `127.0.0.1:8899` | 本地 HTTP 入口 |
147
- | `-server` | `127.0.0.1:9000` | relay-server 的 host:port |
148
- | `-server-name` | 自动推导 | TLS SNI |
170
+ | `-listen` | `127.0.0.1:18899` | 本地 HTTP 入口 |
171
+ | `-server` | `47.82.76.102:28443` | relay-server 的 host:port |
172
+ | `-server-name` | `magpie.aiweb.xin` | TLS SNI(用 IP 连时必须填证书域名)|
173
+ | `-local-addr` | 空 | 绑定出站源地址,绕过代理 TUN 劫持 |
149
174
  | `-transport` | `auto` | `auto` / `quic` / `tcp` |
150
175
  | `-insecure` | `false` | 跳过证书校验(自签观察期)|
151
176
  | `-min-delta` | `16384` | 小于此值的请求体不做 delta |
@@ -168,6 +193,8 @@ go build -o relay-client ./cmd/relay-client
168
193
  | `-tmp-mb` | `512` | 活跃区字节预算 |
169
194
  | `-idem-mb` | `2048` | 幂等区字节预算 |
170
195
  | `-upstream-timeout` | `10m` | 上游请求超时(不随客户端断开取消)|
196
+ | `-health-addr` | 空 | 健康检查监听(如 `127.0.0.1:8080`);留空不启用 |
197
+ | `-health-probe` | `false` | 探针模式:GET 自己的 `/healthz`,成功 exit 0。给 scratch 镜像的 HEALTHCHECK 用(容器内无 curl)|
171
198
 
172
199
  ## 三个关键设计决策
173
200
 
@@ -320,33 +347,38 @@ tail -f ~/.reply9842/client-$(date +%F).jsonl | \
320
347
 
321
348
  ```
322
349
  cmd/
323
- ├── relay-client/ 本地 HTTP 入口 + 基线选择 + delta 编码 + 续传
324
- └── relay-server/ 隧道服务端 + 解码校验 + 上游转发 + spool
350
+ ├── cli/ 本地 HTTP 入口 + 基线选择 + delta 编码 + 续传(命令行)
351
+ ├── gui/ 同一套逻辑的图形界面(mygo native UI + 系统托盘)
352
+ └── server/ 隧道服务端 + 解码校验 + 上游转发 + spool
325
353
  internal/
326
354
  ├── meta/ 隧道帧格式(magic + 版本 + 类型 + 长度前缀)
327
355
  ├── codec/ dcz 编解码、自适应 level、静默损坏防护
356
+ ├── downlink/ 下行响应压缩
328
357
  ├── store/ 内容寻址 LRU(零协商)
329
358
  ├── basefind/ 客户端基线选择(4KB 块哈希命中率)
330
- ├── transport/ QUIC + TCP 双栈 + auto 回退
359
+ ├── transport/ QUIC + TCP 双栈 + auto 回退 + 源地址绑定
360
+ ├── relayclient/ 客户端运行时(配置归一化、runner、设置持久化)
331
361
  ├── spool/ 磁盘双区域流式缓冲(tmp / idem + 字节预算淘汰)
332
362
  ├── idem/ 幂等键(callerKeyId + 请求指纹)
333
363
  ├── clientstream/ 帧 → HTTP 响应 + 续传游标
364
+ ├── autostart/ 开机自启(launchd / HKCU Run)
365
+ ├── glass/ GUI 玻璃材质调色板与 Surface
334
366
  ├── e2e/ 断线续传端到端测试
335
- └── stats/ 逐请求与汇总统计
367
+ └── stats/ 逐请求与汇总统计(JSONL 为事实来源,重启不清零)
336
368
  ```
337
369
 
338
370
  ## 已知限制
339
371
 
340
- 1. **响应不压缩**。当前只优化上行;下行 p50 约 18 KB,暂未处理。
341
- 2. **`resend-full` 的线宽统计只计首次尝试**。DeltaMiss 后的全量重发不计入该行
372
+ 1. **`resend-full` 的线宽统计只计首次尝试**。DeltaMiss 后的全量重发不计入该行
342
373
  `up_wire`,所以含冷启动的窗口其真实占比会高于表格数字(见上文 † 注)。
343
- 3. **冷启动无收益**。服务端首次见到某基线时必然 miss,第一次重发是全量成本(约 20–35%)。
344
- 4. **QUIC 生产未启用**。有回环端到端测试(`TestQUICRoundTrip`)通过,但生产链路仍走
374
+ 2. **冷启动无收益**。服务端首次见到某基线时必然 miss,第一次重发是全量成本(约 20–35%)。
375
+ 3. **QUIC 生产未启用**。有回环端到端测试(`TestQUICRoundTrip`)通过,但生产链路仍走
345
376
  TCP/TLS;启用需放通 UDP 端口并验证中间设备。
346
- 5. **大文档内存**。30 MB 级请求的 delta 需要显著内存,需靠 `PickLevel` 降级。
347
- 6. **单请求体全量缓冲**。客户端与服务端都会把 body 读进内存(为统计与压缩)。
348
- 7. **跨用户字典隔离**。若将来共享字典,必须按租户隔离——混合公私数据的字典存在
377
+ 4. **大文档内存**。30 MB 级请求的 delta 需要显著内存,需靠 `PickLevel` 降级。
378
+ 5. **单请求体全量缓冲**。客户端与服务端都会把 body 读进内存(为统计与压缩)。
379
+ 6. **跨用户字典隔离**。若将来共享字典,必须按租户隔离——混合公私数据的字典存在
349
380
  CRIME/BREACH 类攻击面(RFC 9842 §9.2)。
381
+ 7. **GUI 无 Linux 后端**。mygo 的 native UI 只支持 macOS / Windows,Linux 只有 cli 与 server。
350
382
 
351
383
  ## 规范参考
352
384
 
@@ -358,22 +390,7 @@ internal/
358
390
  | RFC 7694 (Client-Initiated Content-Encoding) | 请求方向编码协商 | 语义参考 |
359
391
  | RFC 8785 (JCS) | JSON 规范化 | 若做块哈希则必需(本方案未用)|
360
392
 
361
- ## 官网与部署
362
-
363
- 官网源码在 [`site/`](./site)(纯 HTML + CSS,无框架、无外部字体、无图片资源),
364
- 线上地址 <https://reply9842.jixoai.com>。
365
-
366
- `site/` 目录:
367
-
368
- ```
369
- site/
370
- ├── index.html 单页落地页(内核数字 / 原理图 / 实测表 / 快速开始)
371
- ├── style.css 全部样式(OKLCH 单品牌色,响应式,含 reduced-motion)
372
- └── CNAME reply9842.jixoai.com
373
- ```
374
-
375
- 推送 `main` 且 `site/**` 有变更时,`.github/workflows/pages.yml` 自动构建并发布到
376
- GitHub Pages(也可在 Actions 页手动 `workflow_dispatch`)。
393
+ ## 官网
377
394
 
378
- DNS 侧 `rfc9842.jixoai.com` 已 CNAME 到 `jixoai.github.io`(Cloudflare、DNS only),
379
- `site/CNAME` 已写好目标域名,Pages 据此绑定自定义域并自动申请 TLS。
395
+ <https://reply9842.jixoai.com>(源码在 [`site/`](./site),纯 HTML + CSS,无框架、
396
+ 无外部字体、无图片资源)。
@@ -0,0 +1,455 @@
1
+ // 断点续传下载器
2
+ //
3
+ // 事故背景(2026-11-13,见 scripts/deploy.sh 顶部):跨境链路被短 timeout 打断,
4
+ // 传了 2.5MB / 9.9MB 的 ELF 就被部署,`file` 仍报 "ELF 64-bit LSB executable"
5
+ // (header 完好),真跑起来 status=11/SEGV。二进制下载是同一个风险面,所以这里:
6
+ //
7
+ // 1. 边收边写盘(createWriteStream 语义),大文件不全量驻留内存;
8
+ // 2. 重试时发 `Range: bytes=<已有长度>-`,.part 文件跨进程/跨镜像存活;
9
+ // 3. 结束时**必须**核对字节数(content-length / content-range 的 total),
10
+ // 不一致直接报错,绝不把半截文件 rename 成正式产物;
11
+ // 4. 可选 SHA256 校验(release 里的 SHA256SUMS.txt),能识别「换了镜像但内容不同」。
12
+
13
+ "use strict";
14
+
15
+ const fs = require("node:fs");
16
+ const path = require("node:path");
17
+ const crypto = require("node:crypto");
18
+ const { DIRECT } = require("./mirror");
19
+
20
+ /** 单次请求的空闲超时:多久收不到新字节就判定连接死了。 */
21
+ const DEFAULT_IDLE_TIMEOUT_MS = 30_000;
22
+
23
+ /** 每个镜像的尝试次数(首次 + 一次重试,重试会带 Range 续传)。 */
24
+ const DEFAULT_ATTEMPTS = 2;
25
+
26
+ // ---- 纯逻辑(可单测) ----
27
+
28
+ /**
29
+ * 续传请求头。已有字节 > 0 才发 Range;否则发空对象(保持普通 GET)。
30
+ * @returns {{Range?: string}}
31
+ */
32
+ function buildRangeHeader(existingBytes) {
33
+ if (!Number.isFinite(existingBytes) || existingBytes <= 0) return {};
34
+ return { Range: `bytes=${existingBytes}-` };
35
+ }
36
+
37
+ /**
38
+ * 解析 `Content-Range`。
39
+ * "bytes 100-200/300" → { start: 100, end: 200, total: 300 }
40
+ * "bytes 100-200/*" → { start: 100, end: 200, total: null }
41
+ * "bytes *​/300" → { start: null, end: null, total: 300 } (416 用)
42
+ */
43
+ function parseContentRange(header) {
44
+ if (typeof header !== "string") return null;
45
+ const m = /^bytes\s+(?:(\d+)-(\d+)|\*)\/(\d+|\*)$/i.exec(header.trim());
46
+ if (!m) return null;
47
+ return {
48
+ start: m[1] === undefined ? null : Number(m[1]),
49
+ end: m[2] === undefined ? null : Number(m[2]),
50
+ total: m[3] === "*" ? null : Number(m[3]),
51
+ };
52
+ }
53
+
54
+ /**
55
+ * 决定「这次响应怎么处理」。纯函数,206/200/416 分支都在这。
56
+ *
57
+ * 关键区分:只有「body 的起点正好等于要写入的偏移」才能用这段 body。
58
+ * append —— 206 且 start == 已有长度(真正的续传)
59
+ * restart —— body 从 0 开始,清空 .part 后可直接写(206 start=0 / 200)
60
+ * refetch —— body 起点与本地长度都对不上(206 start 既不等于已有长度也不是 0,
61
+ * 或 416 且总长 ≠ 已有长度):**这段 body 不可用**,必须删掉 .part
62
+ * 重新发一次不带 Range 的请求
63
+ * complete —— 416 且已有长度正好等于服务端总长(.part 其实已完整)
64
+ *
65
+ * @param {{status:number, existingBytes:number, headers:{get:(n:string)=>string|null}}} args
66
+ * @returns {{action:"append"|"restart"|"refetch"|"complete", total:number|null, reason?:string}}
67
+ */
68
+ function classifyResponse({ status, existingBytes, headers }) {
69
+ const get = (name) => (headers && typeof headers.get === "function"
70
+ ? headers.get(name)
71
+ : null);
72
+
73
+ if (status === 206) {
74
+ const range = parseContentRange(get("content-range"));
75
+ if (!range || range.start === null) {
76
+ throw new Error("服务端返回 206 但缺少合法的 Content-Range");
77
+ }
78
+ const length = Number(get("content-length") || 0);
79
+ if (length > 0 && range.end !== null && length !== range.end - range.start + 1) {
80
+ throw new Error(
81
+ `206 响应的 content-length(${length}) 与 Content-Range(${range.start}-${range.end}) 不一致`,
82
+ );
83
+ }
84
+ if (range.start === existingBytes) {
85
+ return { action: "append", total: range.total };
86
+ }
87
+ if (range.start === 0) {
88
+ // 服务端无视了 Range,但我们拿到的仍是从 0 开始的完整数据,清空重写即可
89
+ return {
90
+ action: "restart",
91
+ total: range.total,
92
+ reason: `服务端从 0 开始返回(本地已有 ${existingBytes}),从头写`,
93
+ };
94
+ }
95
+ // 起点既不是 0 也不是本地长度:body 是中间片段,接到 .part 上会拼出坏文件
96
+ return {
97
+ action: "refetch",
98
+ total: range.total,
99
+ reason: `服务端从 ${range.start} 开始返回,本地已有 ${existingBytes},需重新请求`,
100
+ };
101
+ }
102
+
103
+ if (status === 200) {
104
+ // 服务端不支持 Range(忽略了我们发的头),body 从 0 开始,清空重写
105
+ const total = Number(get("content-length") || 0) || null;
106
+ return {
107
+ action: "restart",
108
+ total,
109
+ reason: existingBytes > 0 ? "服务端不支持 Range,从头下载" : undefined,
110
+ };
111
+ }
112
+
113
+ if (status === 416) {
114
+ const range = parseContentRange(get("content-range"));
115
+ const total = range ? range.total : null;
116
+ if (total !== null && total === existingBytes) {
117
+ return { action: "complete", total };
118
+ }
119
+ // .part 比服务端文件还长(或总长未知):这段残留不可信,丢掉重来
120
+ return {
121
+ action: "refetch",
122
+ total,
123
+ reason: `416:本地 .part(${existingBytes} 字节)与文件实际长度${total === null ? "不符" : `(${total} 字节)不符`},丢弃重来`,
124
+ };
125
+ }
126
+
127
+ throw new Error(`HTTP ${status}`);
128
+ }
129
+
130
+ /** 字节数校验。不一致要报错,不能静默接受半截文件。 */
131
+ function verifySize(actualBytes, expectedBytes, context) {
132
+ if (expectedBytes == null) return; // 服务端没给长度,只能靠 SHA256 兜底
133
+ if (actualBytes !== expectedBytes) {
134
+ throw new Error(
135
+ `下载不完整${context ? `(${context})` : ""}: 实际 ${actualBytes} 字节,` +
136
+ `服务端声明 ${expectedBytes} 字节,相差 ${expectedBytes - actualBytes}`,
137
+ );
138
+ }
139
+ }
140
+
141
+ /** 解析 SHA256SUMS.txt:`<hex> <name>` 每行一条。 */
142
+ function parseChecksums(text) {
143
+ const map = new Map();
144
+ for (const line of String(text || "").split(/\r?\n/)) {
145
+ const m = /^([0-9a-fA-F]{64})\s+\*?(.+?)\s*$/.exec(line);
146
+ if (m) map.set(m[2], m[1].toLowerCase());
147
+ }
148
+ return map;
149
+ }
150
+
151
+ async function sha256File(file) {
152
+ const hash = crypto.createHash("sha256");
153
+ await new Promise((resolve, reject) => {
154
+ const rs = fs.createReadStream(file);
155
+ rs.on("data", (c) => hash.update(c));
156
+ rs.on("error", reject);
157
+ rs.on("end", resolve);
158
+ });
159
+ return hash.digest("hex");
160
+ }
161
+
162
+ // ---- 落盘 ----
163
+
164
+ function tempPath(dest) {
165
+ return `${dest}.part`;
166
+ }
167
+
168
+ /** 记录「第一次下载时服务端声明的总长 / ETag」,用于识别换镜像后内容不同。 */
169
+ function metaPath(dest) {
170
+ return `${dest}.part.json`;
171
+ }
172
+
173
+ async function readMeta(dest) {
174
+ try {
175
+ return JSON.parse(await fs.promises.readFile(metaPath(dest), "utf8"));
176
+ } catch {
177
+ return {};
178
+ }
179
+ }
180
+
181
+ async function writeMeta(dest, meta) {
182
+ try {
183
+ await fs.promises.writeFile(metaPath(dest), JSON.stringify(meta));
184
+ } catch {
185
+ /* meta 只是优化,写不进去不影响正确性 */
186
+ }
187
+ }
188
+
189
+ async function cleanupTemp(dest) {
190
+ for (const p of [tempPath(dest), metaPath(dest)]) {
191
+ await fs.promises.rm(p, { force: true });
192
+ }
193
+ }
194
+
195
+ async function fileSize(file) {
196
+ try {
197
+ return (await fs.promises.stat(file)).size;
198
+ } catch (e) {
199
+ if (e.code === "ENOENT") return 0;
200
+ throw e;
201
+ }
202
+ }
203
+
204
+ /** 丢弃不再需要的响应体(不消费会占着连接,keep-alive 池里泄漏)。 */
205
+ async function discardBody(res) {
206
+ if (!res || !res.body) return;
207
+ try {
208
+ if (typeof res.body.cancel === "function") await res.body.cancel();
209
+ } catch {
210
+ /* 已经结束/已取消 */
211
+ }
212
+ }
213
+
214
+ // ---- 单次传输 ----
215
+
216
+ /**
217
+ * 从单个前缀拉一次,成功返回 { bytes, total }。
218
+ * 失败时 .part 保留(续传素材),只有内容明显不对才删。
219
+ */
220
+ async function downloadOnce({ url, prefix, dest, fetchImpl, onProgress, idleTimeoutMs }) {
221
+ const existing = await fileSize(tempPath(dest));
222
+ const controller = new AbortController();
223
+ let idleTimer = null;
224
+ const armIdle = () => {
225
+ if (!idleTimeoutMs) return;
226
+ if (idleTimer) clearTimeout(idleTimer);
227
+ idleTimer = setTimeout(
228
+ () => controller.abort(new Error(`连接空闲超过 ${idleTimeoutMs}ms`)),
229
+ idleTimeoutMs,
230
+ );
231
+ };
232
+
233
+ armIdle();
234
+ let res;
235
+ try {
236
+ res = await fetchImpl(prefix + url, {
237
+ redirect: "follow",
238
+ headers: buildRangeHeader(existing),
239
+ signal: controller.signal,
240
+ });
241
+ } catch (e) {
242
+ if (idleTimer) clearTimeout(idleTimer);
243
+ throw e;
244
+ }
245
+
246
+ try {
247
+ const plan = classifyResponse({
248
+ status: res.status,
249
+ existingBytes: existing,
250
+ headers: res.headers,
251
+ });
252
+
253
+ if (plan.action === "complete") {
254
+ await discardBody(res);
255
+ return { bytes: existing, total: plan.total };
256
+ }
257
+ if (plan.reason) {
258
+ onProgress({ phase: "info", message: plan.reason });
259
+ }
260
+ if (plan.action === "refetch") {
261
+ // 这段 body 的起点和我们盘上的字节对不上,接到 .part 上会拼出坏文件。
262
+ // 丢掉残留后让上层重发一次不带 Range 的请求(416 / 起点错位都走这里)。
263
+ await discardBody(res);
264
+ await cleanupTemp(dest);
265
+ throw Object.assign(new Error(plan.reason || "需要重新请求"), { restart: true });
266
+ }
267
+
268
+ // 换镜像/服务端声明的总长变了 → 之前下的那段不是这个文件,必须丢
269
+ const meta = await readMeta(dest);
270
+ if (plan.action === "append" && meta.total != null && plan.total != null && meta.total !== plan.total) {
271
+ await cleanupTemp(dest);
272
+ throw Object.assign(
273
+ new Error(`服务端文件大小(${plan.total})与已下载部分(${meta.total})不符,已丢弃重来`),
274
+ { restart: true },
275
+ );
276
+ }
277
+ const etag = res.headers.get("etag");
278
+ if (plan.action === "append" && meta.etag && etag && meta.etag !== etag) {
279
+ await cleanupTemp(dest);
280
+ throw Object.assign(
281
+ new Error("镜像返回的 ETag 与已下载部分不一致,已丢弃重来"),
282
+ { restart: true },
283
+ );
284
+ }
285
+
286
+ const startingAt = plan.action === "append" ? existing : 0;
287
+ const tmp = tempPath(dest);
288
+ await fs.promises.mkdir(path.dirname(dest), { recursive: true });
289
+ const handle = await fs.promises.open(tmp, plan.action === "append" ? "a" : "w");
290
+
291
+ if (plan.action !== "append") {
292
+ // 从头写:旧 meta 已失效,避免下一次续传拿它做总长比对
293
+ await fs.promises.rm(metaPath(dest), { force: true });
294
+ }
295
+ if (plan.total == null) {
296
+ onProgress({
297
+ phase: "info",
298
+ message: "服务端未声明文件长度,只能靠 SHA256 校验完整性",
299
+ });
300
+ }
301
+
302
+ let written = startingAt;
303
+ try {
304
+ if (plan.total != null) {
305
+ await writeMeta(dest, { total: plan.total, etag: etag || meta.etag || null });
306
+ onProgress({ phase: "start", written, total: plan.total, resumed: startingAt > 0 });
307
+ }
308
+ if (res.body) {
309
+ for await (const chunk of res.body) {
310
+ armIdle();
311
+ await handle.write(chunk);
312
+ written += chunk.length;
313
+ if (plan.total) onProgress({ phase: "progress", written, total: plan.total });
314
+ }
315
+ }
316
+ } catch (e) {
317
+ // 网络中断:把已有进度刷盘,交给下一次尝试续传
318
+ await handle.close();
319
+ throw new Error(
320
+ `传输中断(已写入 ${written} 字节${plan.total ? ` / ${plan.total}` : ""}):${e.message}`,
321
+ );
322
+ }
323
+ const st = await handle.stat();
324
+ await handle.close();
325
+
326
+ // ★ 完整性闸门:字节数对不上绝不 rename
327
+ verifySize(st.size, plan.total, prefix ? new URL(prefix).hostname : "直连");
328
+ if (written !== st.size) {
329
+ throw new Error(`写入计数(${written})与磁盘大小(${st.size})不一致`);
330
+ }
331
+ return { bytes: st.size, total: plan.total };
332
+ } catch (e) {
333
+ await discardBody(res); // 复核失败/HTTP 非 2xx:别把响应体挂在那里
334
+ throw e;
335
+ } finally {
336
+ if (idleTimer) clearTimeout(idleTimer);
337
+ }
338
+ }
339
+
340
+ // ---- 多镜像编排 ----
341
+
342
+ function hostOf(prefix) {
343
+ if (prefix === DIRECT) return "github.com(直连)";
344
+ try {
345
+ return new URL(prefix).hostname;
346
+ } catch {
347
+ return prefix;
348
+ }
349
+ }
350
+
351
+ /**
352
+ * 依次尝试镜像下载到 dest(原子 rename)。返回 { bytes, prefix }。
353
+ *
354
+ * 每个镜像默认尝试两次:第一次失败留下的 .part 会被第二次用 Range 续上;
355
+ * 换镜像同样续,因为同一版本同一文件内容一致——但服务端声明的总长或 ETag
356
+ * 变化时判定为不同内容,丢弃重下。
357
+ */
358
+ async function downloadArtifact({
359
+ url,
360
+ dest,
361
+ prefixes,
362
+ fetchImpl = globalThis.fetch,
363
+ onProgress = () => {},
364
+ log = () => {},
365
+ attempts = DEFAULT_ATTEMPTS,
366
+ idleTimeoutMs = DEFAULT_IDLE_TIMEOUT_MS,
367
+ expectedSha256 = null,
368
+ }) {
369
+ const tmp = tempPath(dest);
370
+ let lastErr;
371
+ const list = prefixes && prefixes.length ? prefixes : [DIRECT];
372
+
373
+ for (const prefix of list) {
374
+ const label = hostOf(prefix);
375
+ let attempt = 0;
376
+ let refunded = 0; // 「内容不符 → 丢弃重来」不占用尝试次数,但每个镜像最多退一次
377
+ while (attempt < attempts) {
378
+ attempt++;
379
+ const has = await fileSize(tmp);
380
+ log(` ${label}${attempt > 1 ? `(第 ${attempt} 次尝试)` : ""}${has > 0 ? `,已有 ${(has / 1048576).toFixed(1)}MB 续传` : ""} …`);
381
+ try {
382
+ const { bytes } = await downloadOnce({
383
+ url, prefix, dest, fetchImpl, onProgress, idleTimeoutMs,
384
+ });
385
+
386
+ if (expectedSha256) {
387
+ const actual = await sha256File(tmp);
388
+ if (actual !== expectedSha256) {
389
+ await cleanupTemp(dest);
390
+ throw Object.assign(
391
+ new Error(`SHA256 校验失败(期望 ${expectedSha256.slice(0, 12)}…,实际 ${actual.slice(0, 12)}…),已丢弃`),
392
+ { restart: true },
393
+ );
394
+ }
395
+ }
396
+
397
+ await fs.promises.rename(tmp, dest); // 原子落盘
398
+ await fs.promises.rm(metaPath(dest), { force: true });
399
+ if (process.platform !== "win32") await fs.promises.chmod(dest, 0o755);
400
+ return { bytes, prefix };
401
+ } catch (e) {
402
+ lastErr = e;
403
+ log(` 失败:${e.message}`);
404
+ if (!e.restart) continue; // 网络/HTTP 错误:留给同一镜像的下一次尝试续传
405
+
406
+ // 内容层面的错误(大小/ETag/SHA256 不符):.part 已删,值得立刻从头重来一次
407
+ if (refunded === 0) {
408
+ refunded++;
409
+ attempt--; // 这次不算数
410
+ }
411
+ // 同一个镜像连续两次内容都不对 → 换镜像
412
+ }
413
+ }
414
+ }
415
+ throw lastErr || new Error("全部镜像失败");
416
+ }
417
+
418
+ /** 取 SHA256SUMS.txt(best-effort,任何失败都返回 null,不阻塞下载)。 */
419
+ async function fetchChecksums({ url, prefixes, fetchImpl = globalThis.fetch, idleTimeoutMs = 15_000 }) {
420
+ for (const prefix of prefixes && prefixes.length ? prefixes : [DIRECT]) {
421
+ const controller = new AbortController();
422
+ const timer = idleTimeoutMs
423
+ ? setTimeout(() => controller.abort(new Error("timeout")), idleTimeoutMs)
424
+ : null;
425
+ try {
426
+ const res = await fetchImpl(prefix + url, { redirect: "follow", signal: controller.signal });
427
+ if (!res.ok) continue;
428
+ const text = await res.text();
429
+ const map = parseChecksums(text);
430
+ if (map.size > 0) return map;
431
+ } catch {
432
+ /* 忽略,继续下一个前缀 */
433
+ } finally {
434
+ if (timer) clearTimeout(timer);
435
+ }
436
+ }
437
+ return null;
438
+ }
439
+
440
+ module.exports = {
441
+ DEFAULT_ATTEMPTS,
442
+ DEFAULT_IDLE_TIMEOUT_MS,
443
+ buildRangeHeader,
444
+ classifyResponse,
445
+ cleanupTemp,
446
+ downloadArtifact,
447
+ downloadOnce,
448
+ fetchChecksums,
449
+ metaPath,
450
+ parseChecksums,
451
+ parseContentRange,
452
+ sha256File,
453
+ tempPath,
454
+ verifySize,
455
+ };
package/lib/mirror.js ADDED
@@ -0,0 +1,149 @@
1
+ // 镜像(加速前缀)配置解析
2
+ //
3
+ // 契约(npm 下载器 / Go update 包 / GUI 设置页三处共用,改这里要同步三处):
4
+ //
5
+ // 优先级:REPLY9842_MIRROR 环境变量 > 内置默认列表
6
+ // 格式: 逗号分隔的前缀列表,顺序即优先级(回退链)
7
+ // 特殊值:direct 或空值 = 直连(不加前缀)
8
+ // 拼接: <前缀><完整 url>,前缀末尾自动补 "/",已带则不动
9
+ //
10
+ // 为什么不复用 npm_config_*:npm 会吃掉自己前缀的变量并做一层 config 解析,
11
+ // 用户从 .npmrc 传下来的值语义会变形;REPLY9842_ 前缀无歧义。
12
+
13
+ "use strict";
14
+
15
+ /** 内置默认列表:先国内镜像,最后官方直连兜底。顺序是 2026-11 实测下来的。 */
16
+ const DEFAULT_MIRRORS = [
17
+ "https://gh-proxy.com/",
18
+ "https://ghfast.top/",
19
+ "https://ghproxy.net/",
20
+ "", // 空串 = 直连
21
+ ];
22
+
23
+ /** 覆盖用的环境变量名 */
24
+ const MIRROR_ENV = "REPLY9842_MIRROR";
25
+
26
+ /** 直连前缀(空串,拼接后仍是原 url) */
27
+ const DIRECT = "";
28
+
29
+ function isDirect(token) {
30
+ return token === DIRECT || token.toLowerCase() === "direct";
31
+ }
32
+
33
+ /**
34
+ * 规范化单个前缀:末尾补 "/"。
35
+ * 代理契约是 `<prefix><url>` 直接字符串拼接,少一个斜杠就会拼出
36
+ * `https://my.mirrorhttps://github.com/...` 这种坏 URL。
37
+ */
38
+ function normalizePrefix(prefix) {
39
+ if (prefix === DIRECT) return DIRECT;
40
+ return prefix.endsWith("/") ? prefix : `${prefix}/`;
41
+ }
42
+
43
+ /**
44
+ * 校验前缀是 http(s) URL。
45
+ *
46
+ * 校验放在补斜杠**之后**:`https://m.example.com` 补成 `https://m.example.com/`
47
+ * 才是真正参与拼接的形态,用最终形态校验才等价于 Go 侧。
48
+ */
49
+ function validatePrefix(value, raw) {
50
+ // WHATWG URL 会把 "https:///x" 的额外斜杠当空 authority 丢弃、解析成 host=x,
51
+ // 而 Go 的 url.Parse 得到 Host=""(拒绝)。为了让两侧接受集合**完全相同**,
52
+ // 这里显式要求 authority 非空,不依赖 URL 解析器的宽松行为。
53
+ const authority = /^[a-z][a-z0-9+.-]*:\/\/([^/?#]*)/i.exec(value);
54
+ if (authority && authority[1].trim() === "") {
55
+ throw new Error(
56
+ `${MIRROR_ENV} 里的镜像前缀缺少主机名:${JSON.stringify(raw)}`,
57
+ );
58
+ }
59
+
60
+ let parsed;
61
+ try {
62
+ parsed = new URL(value);
63
+ } catch {
64
+ if (/^https?:\/\/$/i.test(value)) {
65
+ throw new Error(
66
+ `${MIRROR_ENV} 里的镜像前缀缺少主机名:${JSON.stringify(raw)}`,
67
+ );
68
+ }
69
+ throw new Error(
70
+ `${MIRROR_ENV} 里的镜像前缀不是合法 URL:${JSON.stringify(raw)}`,
71
+ );
72
+ }
73
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
74
+ throw new Error(
75
+ `${MIRROR_ENV} 只接受 http(s) 前缀,收到:${JSON.stringify(raw)}`,
76
+ );
77
+ }
78
+ if (!parsed.hostname) {
79
+ throw new Error(
80
+ `${MIRROR_ENV} 里的镜像前缀缺少主机名:${JSON.stringify(raw)}`,
81
+ );
82
+ }
83
+ return value;
84
+ }
85
+
86
+ /**
87
+ * 解析一行镜像配置文本,返回前缀数组(含 "" 表示直连)。
88
+ *
89
+ * 规则:
90
+ * - 逗号切分,每项 trim;
91
+ * - 空项跳过(`a,,b` → [a, b],直连必须显式写 direct);
92
+ * - `direct`(大小写不敏感)与整串为空 → 直连;
93
+ * - 无任何有效项 → 直连(尊重用户显式设置,不回退默认);
94
+ * - 保持顺序去重。
95
+ */
96
+ function parseMirrorList(raw) {
97
+ const text = String(raw == null ? "" : raw).trim();
98
+ if (text === "") return [DIRECT];
99
+
100
+ const out = [];
101
+ const seen = new Set();
102
+ const push = (prefix) => {
103
+ if (seen.has(prefix)) return;
104
+ seen.add(prefix);
105
+ out.push(prefix);
106
+ };
107
+
108
+ for (const piece of text.split(",")) {
109
+ const token = piece.trim();
110
+ if (token === "") continue; // 容忍 `a,,b` 与结尾逗号
111
+ if (isDirect(token)) {
112
+ push(DIRECT);
113
+ continue;
114
+ }
115
+ push(normalizePrefix(validatePrefix(token, piece)));
116
+ }
117
+
118
+ return out.length > 0 ? out : [DIRECT];
119
+ }
120
+
121
+ /**
122
+ * 解析当前生效的镜像列表。
123
+ * @param {NodeJS.ProcessEnv} [env]
124
+ */
125
+ function resolveMirrors(env = process.env) {
126
+ const raw = env ? env[MIRROR_ENV] : undefined;
127
+ if (raw === undefined) return DEFAULT_MIRRORS.slice(); // 未设置 = 默认行为
128
+ return parseMirrorList(raw);
129
+ }
130
+
131
+ /** 给人看的镜像名:直连显示 github.com,其余显示 hostname。 */
132
+ function mirrorLabel(prefix) {
133
+ if (prefix === DIRECT) return "github.com(直连)";
134
+ try {
135
+ return new URL(prefix).hostname;
136
+ } catch {
137
+ return prefix;
138
+ }
139
+ }
140
+
141
+ module.exports = {
142
+ DEFAULT_MIRRORS,
143
+ DIRECT,
144
+ MIRROR_ENV,
145
+ mirrorLabel,
146
+ normalizePrefix,
147
+ parseMirrorList,
148
+ resolveMirrors,
149
+ };
package/lib/run.js CHANGED
@@ -10,8 +10,12 @@
10
10
  // - 用户只为自己的平台付一次下载,而不是装一次拿到 13 个二进制
11
11
  // - 缓存按版本隔离,换版本不会串
12
12
  //
13
- // 国内加速:GitHub Release 直连在国内经常超时。按下列前缀顺序尝试(社区惯例的
14
- // gh-proxy 系列),任一成功即用;全部失败才回退直连。
13
+ // 国内加速:GitHub Release 直连在国内经常超时。按前缀顺序尝试(社区惯例的
14
+ // gh-proxy 系列),任一成功即用;全部失败才回退直连。前缀列表可用
15
+ // REPLY9842_MIRROR 环境变量覆盖,见 lib/mirror.js 的契约说明。
16
+ //
17
+ // 断点续传:见 lib/download.js —— 边收边写 .part,中断后下次启动带 Range 续上,
18
+ // 并在 rename 前核对字节数与 SHA256(防「半截二进制跑出 SEGV」)。
15
19
 
16
20
  "use strict";
17
21
 
@@ -21,15 +25,8 @@ const os = require("node:os");
21
25
  const { spawn } = require("node:child_process");
22
26
 
23
27
  const PKG = require("../package.json");
24
-
25
- // 加速前缀。顺序即优先级:先国内镜像,最后官方直连兜底。
26
- // 这类代理的契约是 `<prefix><完整 url>`,社区通用。
27
- const MIRRORS = [
28
- "https://gh-proxy.com/",
29
- "https://ghfast.top/",
30
- "https://ghproxy.net/",
31
- "", // 空串 = 直连
32
- ];
28
+ const { MIRROR_ENV, mirrorLabel, resolveMirrors } = require("./mirror");
29
+ const { downloadArtifact, fetchChecksums, tempPath } = require("./download");
33
30
 
34
31
  const REPO = "jixoai/reply9842";
35
32
  const KINDS = ["cli", "gui", "server"];
@@ -96,56 +93,81 @@ function releaseUrl(version, name) {
96
93
  return `https://github.com/${REPO}/releases/download/v${version}/${name}`;
97
94
  }
98
95
 
99
- /** 下载到 dest,带进度。mirror 为前缀,空串表示直连。 */
100
- async function download(url, dest, mirror) {
101
- const res = await fetch(mirror + url, { redirect: "follow" });
102
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
103
-
104
- const tmp = `${dest}.part`;
105
- await fs.promises.mkdir(path.dirname(dest), { recursive: true });
96
+ function checksumsUrl(version) {
97
+ return `https://github.com/${REPO}/releases/download/v${version}/SHA256SUMS.txt`;
98
+ }
106
99
 
107
- const total = Number(res.headers.get("content-length") || 0);
108
- const chunks = [];
109
- let got = 0;
100
+ /** 进度条(stderr,10% 一档,避免刷屏拖慢传输) */
101
+ function makeProgress(name) {
110
102
  let lastPct = -1;
111
-
112
- for await (const chunk of res.body) {
113
- chunks.push(chunk);
114
- got += chunk.length;
115
- if (total > 0) {
116
- const pct = Math.floor((got / total) * 100);
117
- if (pct !== lastPct && pct % 10 === 0) {
118
- process.stderr.write(`\r 下载 ${pct}% (${(got / 1048576).toFixed(1)}MB)`);
119
- lastPct = pct;
103
+ let drawn = false;
104
+ const clear = () => {
105
+ if (drawn) process.stderr.write("\r" + " ".repeat(52) + "\r");
106
+ drawn = false;
107
+ };
108
+ return {
109
+ onProgress(ev) {
110
+ if (ev.phase === "info") {
111
+ process.stderr.write(` ${ev.message}\n`);
112
+ return;
120
113
  }
121
- }
122
- }
123
- if (total > 0) process.stderr.write("\r" + " ".repeat(40) + "\r");
124
-
125
- await fs.promises.writeFile(tmp, Buffer.concat(chunks));
126
- await fs.promises.rename(tmp, dest); // 原子落盘
127
- if (process.platform !== "win32") await fs.promises.chmod(dest, 0o755);
128
- }
129
-
130
- /** 依次尝试镜像,任一成功即返回 */
131
- async function downloadWithMirrors(url, dest) {
132
- let lastErr;
133
- for (const m of MIRRORS) {
134
- const label = m ? new URL(m).hostname : "github.com(直连)";
135
- process.stderr.write(` 尝试 ${label} …\n`);
136
- try {
137
- await download(url, dest, m);
138
- return true;
139
- } catch (e) {
140
- lastErr = e;
141
- process.stderr.write(` 失败:${e.message}\n`);
142
- }
114
+ if (ev.phase === "start" && ev.resumed) {
115
+ process.stderr.write(
116
+ ` 续传:从 ${(ev.written / 1048576).toFixed(1)}MB / ${(ev.total / 1048576).toFixed(1)}MB 继续\n`,
117
+ );
118
+ lastPct = -1;
119
+ }
120
+ if (ev.phase !== "progress" || !ev.total) return;
121
+ const pct = Math.floor((ev.written / ev.total) * 100);
122
+ if (pct === lastPct) return;
123
+ if (pct % 10 !== 0 && pct !== 100) return;
124
+ lastPct = pct;
125
+ drawn = true;
126
+ process.stderr.write(
127
+ `\r 下载 ${name} ${String(pct).padStart(3)}% (${(ev.written / 1048576).toFixed(1)}MB)`,
128
+ );
129
+ },
130
+ clear,
131
+ };
132
+ }
133
+
134
+ /** 依次尝试镜像下载,返回实际使用的镜像前缀 */
135
+ async function downloadWithMirrors(url, dest, { prefixes, expectedSha256 } = {}) {
136
+ const list = prefixes || resolveMirrors();
137
+ const prog = makeProgress(path.basename(dest));
138
+ try {
139
+ const { bytes, prefix } = await downloadArtifact({
140
+ url,
141
+ dest,
142
+ prefixes: list,
143
+ expectedSha256,
144
+ onProgress: prog.onProgress,
145
+ log: (line) => process.stderr.write(line + "\n"),
146
+ });
147
+ return { bytes, prefix, mirrors: list };
148
+ } finally {
149
+ prog.clear();
143
150
  }
144
- throw lastErr || new Error("全部镜像失败");
145
151
  }
146
152
 
147
153
  // ---- 主流程 ----
148
154
 
155
+ /**
156
+ * 解析镜像列表,失败时给出可操作提示并退出(不甩栈:这是配置错误,不是程序缺陷)。
157
+ * @returns {string[]|null} 且在出错时已 process.exit
158
+ */
159
+ function effectiveMirrors() {
160
+ try {
161
+ return resolveMirrors();
162
+ } catch (e) {
163
+ process.stderr.write(
164
+ `reply9842: ${MIRROR_ENV} 配置有误 —— ${e.message}\n` +
165
+ ` 示例: ${MIRROR_ENV}=https://gh-proxy.com/ 或 ${MIRROR_ENV}=direct\n`,
166
+ );
167
+ process.exit(1);
168
+ }
169
+ }
170
+
149
171
  function usage(kind) {
150
172
  const bins = {
151
173
  cli: "reply9842 [参数...]",
@@ -159,7 +181,23 @@ function usage(kind) {
159
181
  ` ${bins[kind] || "reply9842 <cli|gui|server> [参数...]"}`,
160
182
  "",
161
183
  "首次运行会从 GitHub Release 下载对应平台的二进制(8-17MB),",
162
- "缓存后不再重复下载。国内网络自动走 gh-proxy 加速。",
184
+ "缓存后不再重复下载。中断后再次运行会从断点续传(.part 落盘)。",
185
+ "",
186
+ "镜像(加速前缀)配置:",
187
+ ` ${MIRROR_ENV}=<前缀>[,<前缀>...]`,
188
+ "",
189
+ " - 逗号分隔,顺序即优先级(回退链);每项两侧空白会被忽略,空项跳过",
190
+ " - 前缀末尾的 / 可省略,会自动补全",
191
+ " - 写 direct 表示直连(如 REPLY9842_MIRROR=direct);整串留空同样视为直连",
192
+ " - 只写前缀,程序会在后面拼完整 URL:<前缀>https://github.com/...",
193
+ " - 不设置该变量时使用下面内置的默认顺序",
194
+ "",
195
+ " 例:",
196
+ ` ${MIRROR_ENV}=https://gh-proxy.com/ npx reply9842`,
197
+ ` ${MIRROR_ENV}="https://my.mirror/,https://gh-proxy.com/,direct" npx reply9842`,
198
+ "",
199
+ " 当前生效顺序:",
200
+ ...effectiveMirrors().map((p, i) => ` ${i + 1}. ${mirrorLabel(p)}${p ? ` (${p})` : ""}`),
163
201
  "",
164
202
  `缓存目录: ${cacheRoot()}`,
165
203
  "",
@@ -167,8 +205,8 @@ function usage(kind) {
167
205
  }
168
206
 
169
207
  /** 根据调用名或首参数决定 kind */
170
- function resolveKind(argv) {
171
- const invoked = path.basename(process.argv[1] || "");
208
+ function resolveKind(argv, argv1 = process.argv[1]) {
209
+ const invoked = path.basename(argv1 || "");
172
210
  if (invoked === "reply9842-gui") return { kind: "gui", args: argv };
173
211
  if (invoked === "reply9842-server") return { kind: "server", args: argv };
174
212
  if (argv.length > 0 && KINDS.includes(argv[0])) {
@@ -197,25 +235,49 @@ async function ensureBinary(kind, version) {
197
235
  /* 未缓存,继续下载 */
198
236
  }
199
237
 
200
- process.stderr.write(`reply9842: 首次运行,下载 ${name}(v${version})\n`);
201
238
  const url = releaseUrl(version, name);
239
+ const prefixes = effectiveMirrors();
240
+ const partialBytes = await fs.promises
241
+ .stat(tempPath(dest))
242
+ .then((s) => s.size)
243
+ .catch(() => 0);
244
+
245
+ process.stderr.write(
246
+ `reply9842: 首次运行,下载 ${name}(v${version})` +
247
+ (partialBytes > 0
248
+ ? `,检测到未完成的下载 ${(partialBytes / 1048576).toFixed(1)}MB,将续传\n`
249
+ : "\n"),
250
+ );
251
+
252
+ // SHA256SUMS.txt 是 best-effort:取不到就只靠字节数校验,不阻塞下载
253
+ let expectedSha256 = null;
254
+ const sums = await fetchChecksums({ url: checksumsUrl(version), prefixes });
255
+ if (sums) expectedSha256 = sums.get(name) || null;
256
+
202
257
  try {
203
- await downloadWithMirrors(url, dest);
204
- process.stderr.write(` 已缓存到 ${dest}\n`);
258
+ const { bytes, prefix } = await downloadWithMirrors(url, dest, {
259
+ prefixes,
260
+ expectedSha256,
261
+ });
262
+ process.stderr.write(
263
+ ` 已缓存到 ${dest}(${(bytes / 1048576).toFixed(1)}MB,来源 ${mirrorLabel(prefix)}` +
264
+ `${expectedSha256 ? ",SHA256 已校验" : ""})\n`,
265
+ );
205
266
  return dest;
206
267
  } catch {
207
268
  process.stderr.write(
208
269
  `\nreply9842: 下载失败。\n` +
209
270
  ` 手动下载:${url}\n` +
210
271
  ` 放到:${dest}\n` +
211
- ` 或源码构建:go install github.com/${REPO}/cmd/${kind}@v${version}\n`,
272
+ ` 或源码构建:go install github.com/${REPO}/cmd/${kind}@v${version}\n` +
273
+ ` 镜像加速:${MIRROR_ENV}=https://gh-proxy.com/ npx reply9842\n`,
212
274
  );
213
275
  process.exit(1);
214
276
  }
215
277
  }
216
278
 
217
- async function main() {
218
- const { kind, args } = resolveKind(process.argv.slice(2));
279
+ async function main(argv = process.argv.slice(2)) {
280
+ const { kind, args } = resolveKind(argv);
219
281
 
220
282
  if (args[0] === "-h" || args[0] === "--help") {
221
283
  usage(kind);
@@ -247,7 +309,33 @@ async function main() {
247
309
  });
248
310
  }
249
311
 
250
- main().catch((e) => {
251
- process.stderr.write(`reply9842: ${e.stack || e.message}\n`);
252
- process.exit(1);
253
- });
312
+ /** 只有被 bin/*.js 或直接 node lib/run.js 启动时才跑主流程,测试里 require 保持纯函数可测 */
313
+ function invokedAsEntry() {
314
+ if (require.main === module) return true;
315
+ const name = path.basename((require.main && require.main.filename) || "");
316
+ return (
317
+ name === "reply9842.js" ||
318
+ name === "reply9842-gui.js" ||
319
+ name === "reply9842-server.js"
320
+ );
321
+ }
322
+
323
+ if (invokedAsEntry()) {
324
+ main().catch((e) => {
325
+ process.stderr.write(`reply9842: ${e.stack || e.message}\n`);
326
+ process.exit(1);
327
+ });
328
+ }
329
+
330
+ module.exports = {
331
+ artifactName,
332
+ binaryPath,
333
+ cacheRoot,
334
+ ensureBinary,
335
+ goarch,
336
+ goos,
337
+ main,
338
+ releaseUrl,
339
+ resolveKind,
340
+ usage,
341
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "reply9842",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "description": "RFC 9842 增量压缩隧道:把 AI Agent 的上行带宽砍掉 99.8%。启动器按需下载对应平台的 cli / gui / server 二进制",
5
5
  "keywords": [
6
6
  "rfc9842",