difftab 0.1.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/LICENSE +21 -0
- package/README.md +156 -0
- package/README.zh-CN.md +137 -0
- package/bin/difftab.js +67 -0
- package/dist/server/main.js +2305 -0
- package/dist/web/app.css +3 -0
- package/dist/web/app.js +122 -0
- package/dist/web/index.html +13 -0
- package/package.json +80 -0
|
@@ -0,0 +1,2305 @@
|
|
|
1
|
+
import { closeSync, mkdirSync, openSync, readFileSync, realpathSync, statSync, unlinkSync, watch, writeFileSync, writeSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
|
+
import { spawn } from "node:child_process";
|
|
6
|
+
import { createServer, request } from "node:http";
|
|
7
|
+
import { homedir, tmpdir } from "node:os";
|
|
8
|
+
import { access, lstat, readFile, readlink } from "node:fs/promises";
|
|
9
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
10
|
+
|
|
11
|
+
//#region src/server/cli/args.ts
|
|
12
|
+
var CliUsageError = class extends Error {};
|
|
13
|
+
const HELP_TEXT = `difftab — see what changed in this repository (read-only)
|
|
14
|
+
|
|
15
|
+
Usage:
|
|
16
|
+
difftab [options]
|
|
17
|
+
|
|
18
|
+
Options:
|
|
19
|
+
--no-open print the URL instead of opening a browser
|
|
20
|
+
-v, --version print the version and exit
|
|
21
|
+
-h, --help print this help and exit
|
|
22
|
+
|
|
23
|
+
difftab never writes to your repository.
|
|
24
|
+
`;
|
|
25
|
+
function parseCliArgs(argv) {
|
|
26
|
+
try {
|
|
27
|
+
const { values } = parseArgs({
|
|
28
|
+
args: [...argv],
|
|
29
|
+
options: {
|
|
30
|
+
version: {
|
|
31
|
+
type: "boolean",
|
|
32
|
+
short: "v"
|
|
33
|
+
},
|
|
34
|
+
help: {
|
|
35
|
+
type: "boolean",
|
|
36
|
+
short: "h"
|
|
37
|
+
},
|
|
38
|
+
"no-open": { type: "boolean" }
|
|
39
|
+
},
|
|
40
|
+
strict: true,
|
|
41
|
+
allowPositionals: false
|
|
42
|
+
});
|
|
43
|
+
return {
|
|
44
|
+
version: values.version === true,
|
|
45
|
+
help: values.help === true,
|
|
46
|
+
noOpen: values["no-open"] === true
|
|
47
|
+
};
|
|
48
|
+
} catch (cause) {
|
|
49
|
+
throw new CliUsageError(cause instanceof Error ? cause.message : String(cause));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
//#endregion
|
|
54
|
+
//#region src/server/git/run.ts
|
|
55
|
+
/**
|
|
56
|
+
* 所有 git 调用统一注入,不留给各调用点自己记得加(spec §5.2)。
|
|
57
|
+
*
|
|
58
|
+
* `-z` 只作用于 status / numstat 这类**列表输出**,管不到 `git diff` 的补丁正文 ——
|
|
59
|
+
* 正文里的 `diff --git` / `---` / `+++` / `rename from|to` 头部行仍会按 C 风格转义
|
|
60
|
+
* (已实测),而 diff2html 恰恰是从这些头部行解析文件名的。两者互补,不可相互替代。
|
|
61
|
+
*
|
|
62
|
+
* 注入位置必须在子命令**之前**:`-c` 是 git 的全局选项,放在子命令后面 git 不认。
|
|
63
|
+
*/
|
|
64
|
+
const GLOBAL_CONFIG = ["-c", "core.quotePath=false"];
|
|
65
|
+
/**
|
|
66
|
+
* 子进程环境。
|
|
67
|
+
*
|
|
68
|
+
* `GIT_OPTIONAL_LOCKS=0` 是只读承诺的一部分而不是性能开关:默认情况下 `git status`
|
|
69
|
+
* 会顺手把刷新过的 stat 缓存写回 `.git/index`。那不改变 status 的输出,因此
|
|
70
|
+
* 「前后 `git status` 比对」这类验证发现不了它(§5.10 排除该做法的原因之一)。
|
|
71
|
+
*
|
|
72
|
+
* **把这一行删掉,只读 `.git` 那半层门禁照样全绿**(已实测,见 §10):git 把 index
|
|
73
|
+
* 回写当 best-effort,`.git` 不可写时它静默跳过、exit 0、stderr 全空。真正看得见
|
|
74
|
+
* 的是 §5.10 第二层的 **B 半**——在可写的 `.git` 上前后做逐字节快照比对
|
|
75
|
+
* (`test/smoke/readonly-git-dir.test.js`)。别把 B 半当成 A 半的重复给删了。
|
|
76
|
+
*
|
|
77
|
+
* 该变量在 git < 2.15 上不存在,设了也无害 —— 那个区间的 git 只是照旧写 index。
|
|
78
|
+
*
|
|
79
|
+
* `GIT_TERMINAL_PROMPT=0` 防止任何意外的凭据交互把无人值守的进程挂住。
|
|
80
|
+
*
|
|
81
|
+
* **`GIT_LITERAL_PATHSPECS=1` 是安全项而不是洁癖**:`--` 后面的路径默认是 **wildmatch
|
|
82
|
+
* 模式**,不是字面路径。我们的路径全部来自 URL query,于是 `path=*` 会让
|
|
83
|
+
* `git diff HEAD -- '*'` 回一份**整仓 diff**(已实测)——正是 §5.2 明令禁止、
|
|
84
|
+
* 会把浏览器主线程冻上数十秒的那件事;而一个真实存在、名字里带 `*` 的文件同样会
|
|
85
|
+
* 匹配到别人身上,页面在 A 的标题下显示 B 的补丁。设了它,`*` / `?` / `[…]` 一律
|
|
86
|
+
* 按字面比较。本项目的路径无一例外来自 git 自己的输出,不需要任何通配语义。
|
|
87
|
+
*/
|
|
88
|
+
const GIT_ENV = {
|
|
89
|
+
GIT_OPTIONAL_LOCKS: "0",
|
|
90
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
91
|
+
GIT_LITERAL_PATHSPECS: "1"
|
|
92
|
+
};
|
|
93
|
+
/**
|
|
94
|
+
* stdout 的**兜底**上限。没有它,一个几百 MB 的文件的 diff 会被整个读进内存,
|
|
95
|
+
* 而这条路径上没有任何东西会先失败。
|
|
96
|
+
*
|
|
97
|
+
* 产品语义那道 5MB 闸不在这里,而是由调用方按次传 `maxStdoutBytes`(§5.2 的
|
|
98
|
+
* 「卡补丁字节数」——见 git/diff.ts):**能不能渲染取决于补丁多大,而不是文件多大**,
|
|
99
|
+
* 一个 6MB 的数据文件改一行照样该看得见。本常量只管「别把进程撑爆」。
|
|
100
|
+
*/
|
|
101
|
+
const MAX_STDOUT_BYTES = 67108864;
|
|
102
|
+
var GitError = class extends Error {
|
|
103
|
+
kind;
|
|
104
|
+
args;
|
|
105
|
+
stderr;
|
|
106
|
+
code;
|
|
107
|
+
constructor(kind, args, stderr, code) {
|
|
108
|
+
super(`git ${args.join(" ")} failed (${kind})`);
|
|
109
|
+
this.name = "GitError";
|
|
110
|
+
this.kind = kind;
|
|
111
|
+
this.args = args;
|
|
112
|
+
this.stderr = stderr;
|
|
113
|
+
this.code = code;
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
/**
|
|
117
|
+
* 跑一条 git 命令。**非零退出不抛异常** —— 交给调用方判断,因为若干只读探测
|
|
118
|
+
* (空仓库下的 `rev-parse --verify HEAD`、下限之下的 `--show-object-format`)
|
|
119
|
+
* 正是靠非零退出来给出答案的。
|
|
120
|
+
*/
|
|
121
|
+
function runGit(args, cwd, options = {}) {
|
|
122
|
+
const limit = Math.min(options.maxStdoutBytes ?? MAX_STDOUT_BYTES, MAX_STDOUT_BYTES);
|
|
123
|
+
return new Promise((resolvePromise, rejectPromise) => {
|
|
124
|
+
const argv = [...GLOBAL_CONFIG, ...args];
|
|
125
|
+
const child = spawn("git", argv, {
|
|
126
|
+
cwd,
|
|
127
|
+
env: {
|
|
128
|
+
...process.env,
|
|
129
|
+
...GIT_ENV
|
|
130
|
+
},
|
|
131
|
+
stdio: [
|
|
132
|
+
"ignore",
|
|
133
|
+
"pipe",
|
|
134
|
+
"pipe"
|
|
135
|
+
]
|
|
136
|
+
});
|
|
137
|
+
const out = [];
|
|
138
|
+
const err = [];
|
|
139
|
+
let outBytes = 0;
|
|
140
|
+
let overflowed = false;
|
|
141
|
+
child.stdout.on("data", (chunk) => {
|
|
142
|
+
outBytes += chunk.length;
|
|
143
|
+
if (outBytes > limit) {
|
|
144
|
+
overflowed = true;
|
|
145
|
+
child.kill();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
out.push(chunk);
|
|
149
|
+
});
|
|
150
|
+
child.stderr.on("data", (chunk) => {
|
|
151
|
+
err.push(chunk);
|
|
152
|
+
});
|
|
153
|
+
/**
|
|
154
|
+
* **`kill()` 之后到达的报错是这次 kill 的后果,不是新事实。**
|
|
155
|
+
*
|
|
156
|
+
* 实测(CI run 31755485278):超限掐断 git 之后,**windows × Node 22.0.x 那一档**
|
|
157
|
+
* 会走到下面的 `'error'` 分支,于是 `kind` 成了 `exit`——调用方认不出「补丁太大」
|
|
158
|
+
* 这个结论,`too-large` 那条分支静默变成一个 500;另外 8 档与本机全绿,因为那里
|
|
159
|
+
* 这个事件根本不触发。至于报的是 EPIPE(git 还写在管道上)还是 kill 本身失败,
|
|
160
|
+
* 无从也无需区分:**判定超限之后,任何错误都是我们自己动手的结果。**
|
|
161
|
+
*
|
|
162
|
+
* 所以只要已经判定超限,一律以 `overflow` 收尾;流上的 `'error'` 同样咽掉 ——
|
|
163
|
+
* 一个没人监听的流错误会以未捕获异常掀掉整个服务,而进程的死活由下面两个
|
|
164
|
+
* 事件负责报告,不需要它再说一遍。
|
|
165
|
+
*/
|
|
166
|
+
const settleOverflow = () => rejectPromise(new GitError("overflow", argv, "", null));
|
|
167
|
+
for (const stream of [child.stdout, child.stderr]) stream.on("error", () => {
|
|
168
|
+
if (overflowed) settleOverflow();
|
|
169
|
+
});
|
|
170
|
+
child.on("error", (cause) => {
|
|
171
|
+
if (overflowed) {
|
|
172
|
+
settleOverflow();
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
rejectPromise(new GitError(cause.code === "ENOENT" ? "missing" : "exit", argv, String(cause.message), null));
|
|
176
|
+
});
|
|
177
|
+
child.on("close", (code) => {
|
|
178
|
+
const stderr = Buffer.concat(err).toString("utf8");
|
|
179
|
+
if (overflowed) {
|
|
180
|
+
rejectPromise(new GitError("overflow", argv, stderr, code));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
resolvePromise({
|
|
184
|
+
stdout: Buffer.concat(out).toString("utf8"),
|
|
185
|
+
stderr,
|
|
186
|
+
code
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
/** 同上,但非零退出即抛。用于「失败没有第二种解释」的调用点。 */
|
|
192
|
+
async function runGitStrict(args, cwd) {
|
|
193
|
+
const result = await runGit(args, cwd);
|
|
194
|
+
if (result.code !== 0) throw new GitError("exit", args, result.stderr, result.code);
|
|
195
|
+
return result.stdout;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
//#endregion
|
|
199
|
+
//#region src/server/git/repo.ts
|
|
200
|
+
/** `--porcelain=v2` 的最低要求(spec §5.2)。 */
|
|
201
|
+
const MIN_GIT = {
|
|
202
|
+
major: 2,
|
|
203
|
+
minor: 11
|
|
204
|
+
};
|
|
205
|
+
/**
|
|
206
|
+
* 空树对象哈希。空仓库下 HEAD 不存在、`git diff HEAD` 直接 fatal,改用它作 diff 基准
|
|
207
|
+
* 即可(spec §5.3),不必为空仓库写一条特殊分支。
|
|
208
|
+
*
|
|
209
|
+
* 硬编码是**要求**而不是偷懒:`git hash-object -t tree /dev/null` 依赖 `/dev/null`,
|
|
210
|
+
* Windows 上不可移植;`git mktree` 会写对象库,直接违反只读承诺。
|
|
211
|
+
*/
|
|
212
|
+
const EMPTY_TREE = {
|
|
213
|
+
sha1: "4b825dc642cb6eb9a060e54bf8d69288fbee4904",
|
|
214
|
+
sha256: "6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321"
|
|
215
|
+
};
|
|
216
|
+
/** 启动前置检查失败。CLI 据此打印一句话友好报错,而不是抛 Node 异常栈(§5.2)。 */
|
|
217
|
+
var PreflightError = class extends Error {
|
|
218
|
+
code;
|
|
219
|
+
constructor(code, message) {
|
|
220
|
+
super(message);
|
|
221
|
+
this.name = "PreflightError";
|
|
222
|
+
this.code = code;
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
/** `git version 2.50.1 (Apple Git-155)` → `{ major: 2, minor: 50 }`。 */
|
|
226
|
+
function parseGitVersion(output) {
|
|
227
|
+
const m = /\bversion\s+(\d+)\.(\d+)/.exec(output);
|
|
228
|
+
if (!m) return null;
|
|
229
|
+
return {
|
|
230
|
+
major: Number(m[1]),
|
|
231
|
+
minor: Number(m[2])
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
function tooOld(v) {
|
|
235
|
+
return v.major < MIN_GIT.major || v.major === MIN_GIT.major && v.minor < MIN_GIT.minor;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* 前置检查 + 定位,一次做完。
|
|
239
|
+
*
|
|
240
|
+
* 两条命令并发跑:串行等于把两次进程启动开销叠加进 §6 的 300ms 冷启动预算,
|
|
241
|
+
* 而它们之间没有依赖。失败路径上的额外一次 `rev-parse` 不计成本 —— 那条路径的
|
|
242
|
+
* 终点是打印一行报错然后退出。
|
|
243
|
+
*/
|
|
244
|
+
async function locateRepo(cwd) {
|
|
245
|
+
const [version, located] = await Promise.all([runGit(["--version"], cwd), runGit([
|
|
246
|
+
"rev-parse",
|
|
247
|
+
"--show-toplevel",
|
|
248
|
+
"--git-dir"
|
|
249
|
+
], cwd)]).catch((cause) => {
|
|
250
|
+
if (cause instanceof GitError && cause.kind === "missing") throw new PreflightError("git-missing", "git was not found on your PATH. Install git and try again.");
|
|
251
|
+
throw cause;
|
|
252
|
+
});
|
|
253
|
+
const parsed = version.code === 0 ? parseGitVersion(version.stdout) : null;
|
|
254
|
+
if (parsed && tooOld(parsed)) throw new PreflightError("git-too-old", `difftab needs git ${MIN_GIT.major}.${MIN_GIT.minor} or newer (for --porcelain=v2), but found ${parsed.major}.${parsed.minor}.`);
|
|
255
|
+
if (located.code !== 0) {
|
|
256
|
+
const bare = await runGit(["rev-parse", "--is-bare-repository"], cwd);
|
|
257
|
+
if (bare.code === 0 && bare.stdout.trim() === "true") throw new PreflightError("bare-repo", "this is a bare repository — it has no working tree to show changes for.");
|
|
258
|
+
throw new PreflightError("not-a-repo", "this directory is not inside a git repository.");
|
|
259
|
+
}
|
|
260
|
+
const lines = located.stdout.split("\n");
|
|
261
|
+
const root = lines[0]?.trim();
|
|
262
|
+
const gitDir = lines[1]?.trim();
|
|
263
|
+
if (!root || !gitDir) throw new PreflightError("not-a-repo", "this directory is not inside a git repository.");
|
|
264
|
+
return {
|
|
265
|
+
root,
|
|
266
|
+
gitDir: isAbsolute(gitDir) ? gitDir : resolve(root, gitDir)
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* diff 的基准。
|
|
271
|
+
*
|
|
272
|
+
* 正常仓库是 `HEAD`;空仓库(尚无任何提交)下 HEAD 不存在,`git diff HEAD` 会 fatal,
|
|
273
|
+
* 降级为空树哈希(spec §5.3)。
|
|
274
|
+
*
|
|
275
|
+
* 不做缓存:`git checkout --orphan` 之后 HEAD 会重新变回未出生状态,缓存的正结果
|
|
276
|
+
* 会让 diff 从此全部 fatal。这条只在取 diff 时调用,不落在冷启动路径上。
|
|
277
|
+
*/
|
|
278
|
+
async function resolveDiffBase(root) {
|
|
279
|
+
const head = await runGit([
|
|
280
|
+
"rev-parse",
|
|
281
|
+
"--verify",
|
|
282
|
+
"--quiet",
|
|
283
|
+
"HEAD"
|
|
284
|
+
], root);
|
|
285
|
+
if (head.code === 0 && head.stdout.trim()) return "HEAD";
|
|
286
|
+
const format = await runGit(["rev-parse", "--show-object-format"], root);
|
|
287
|
+
return (format.code === 0 ? format.stdout.trim() : "sha1") === "sha256" ? EMPTY_TREE.sha256 : EMPTY_TREE.sha1;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
//#endregion
|
|
291
|
+
//#region src/server/git/diff.ts
|
|
292
|
+
/**
|
|
293
|
+
* 超过这个字节数只提示、不预览(spec §3 / §5.2)。
|
|
294
|
+
*
|
|
295
|
+
* **两条路量的东西不同,但量的都是「前端要吃下多少字节」**:未跟踪那一侧整份文件
|
|
296
|
+
* 就是补丁,所以量文件(还省得把它读进来);已跟踪那一侧补丁只含改动与上下文,
|
|
297
|
+
* 所以量补丁本身(见 `trackedDiff`)。
|
|
298
|
+
*/
|
|
299
|
+
const MAX_BYTES = 5242880;
|
|
300
|
+
/**
|
|
301
|
+
* 行数上限。体积阈值挡不住另一头:超长行数的窄文件体积不大,但逐行构造 diff 与
|
|
302
|
+
* 前端渲染同样会卡(spec §5.2)。
|
|
303
|
+
*
|
|
304
|
+
* 已跟踪那一侧数的是**改动行数**(numstat 的加 + 减),未跟踪数的是文件行数 ——
|
|
305
|
+
* 后者本来就整份都是新增行。两边量的都是「前端要渲染多少行」。
|
|
306
|
+
*/
|
|
307
|
+
const MAX_LINES = 5e4;
|
|
308
|
+
var DiffRequestError = class extends Error {
|
|
309
|
+
code;
|
|
310
|
+
constructor(code, message) {
|
|
311
|
+
super(message);
|
|
312
|
+
this.name = "DiffRequestError";
|
|
313
|
+
this.code = code;
|
|
314
|
+
}
|
|
315
|
+
};
|
|
316
|
+
/**
|
|
317
|
+
* 把请求里的仓库相对路径落到磁盘上,并保证它没有走出仓库。
|
|
318
|
+
*
|
|
319
|
+
* 路径来自 URL query,是外部输入。`git diff -- <pathspec>` 自身受仓库边界约束,
|
|
320
|
+
* 但未跟踪文件那条路径要**直接读磁盘**,没有这道检查就是一个路径穿越。
|
|
321
|
+
*/
|
|
322
|
+
function resolveInRepo(root, path) {
|
|
323
|
+
if (!path || isAbsolute(path) || path.includes("\0")) throw new DiffRequestError("invalid-path", "invalid path");
|
|
324
|
+
const abs = resolve(root, path);
|
|
325
|
+
const rel = relative(root, abs);
|
|
326
|
+
if (rel === "" || rel.startsWith("..") || rel.startsWith(`..${sep}`) || isAbsolute(rel)) throw new DiffRequestError("invalid-path", "invalid path");
|
|
327
|
+
return abs;
|
|
328
|
+
}
|
|
329
|
+
/** `-z` 输出里是否原样出现这条路径。 */
|
|
330
|
+
function lists(output, path) {
|
|
331
|
+
return output.split("\0").some((entry) => entry === path);
|
|
332
|
+
}
|
|
333
|
+
/** 路径在 index 里。 */
|
|
334
|
+
async function inIndex(root, path) {
|
|
335
|
+
return lists(await runGitStrict([
|
|
336
|
+
"ls-files",
|
|
337
|
+
"-z",
|
|
338
|
+
"--",
|
|
339
|
+
path
|
|
340
|
+
], root), path);
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* `git diff --numstat -z` 的解析。**重命名记录不是一段而是三段。**
|
|
344
|
+
*
|
|
345
|
+
* 实测(见 spec §10):普通记录是 `<加>\t<减>\t<路径>`,一条占一个 NUL 段;
|
|
346
|
+
* 重命名记录的**路径字段是空的**,后面紧跟两段 `<旧路径>` `<新路径>` ——
|
|
347
|
+
* 与 `status --porcelain=v2` 的 `2 ` 记录**顺序相反**(那边是新在前、旧在后)。
|
|
348
|
+
* 无状态地按 NUL 平铺切分,就会把 `<旧路径>` 当成下一条记录的开头。
|
|
349
|
+
*
|
|
350
|
+
* 本项目每次只问一条路径,所以「多吞两段」眼下不改变结果 —— 但把它写对的成本是
|
|
351
|
+
* 三行,而写错的症状是将来某天改成批量查询后,一半记录静默错位。
|
|
352
|
+
*/
|
|
353
|
+
function parseNumstat(output) {
|
|
354
|
+
const segments = output.split("\0");
|
|
355
|
+
const records = [];
|
|
356
|
+
for (let i = 0; i < segments.length; i += 1) {
|
|
357
|
+
const match = /^(-|\d+)\t(-|\d+)\t(.*)$/s.exec(segments[i] ?? "");
|
|
358
|
+
if (!match) continue;
|
|
359
|
+
const binary = match[1] === "-" || match[2] === "-";
|
|
360
|
+
let path = match[3];
|
|
361
|
+
let oldPath = null;
|
|
362
|
+
if (path === "") {
|
|
363
|
+
oldPath = segments[i + 1] ?? "";
|
|
364
|
+
path = segments[i + 2] ?? "";
|
|
365
|
+
i += 2;
|
|
366
|
+
}
|
|
367
|
+
records.push({
|
|
368
|
+
binary,
|
|
369
|
+
lines: binary ? 0 : Number(match[1]) + Number(match[2]),
|
|
370
|
+
path,
|
|
371
|
+
oldPath
|
|
372
|
+
});
|
|
373
|
+
}
|
|
374
|
+
return records;
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* 「基准 → 工作区」这条路径上的 numstat 记录;不在差异里就是 `null`。
|
|
378
|
+
*
|
|
379
|
+
* 这一次调用同时回答三个问题,所以它取代了原先那次 `diff --name-only`:
|
|
380
|
+
* 1. **它是不是已跟踪的**。`ls-files` 只答得出 index 里有什么,而已跟踪是
|
|
381
|
+
* **HEAD ∪ index**:`git rm` 之后已暂存的删除在 index 里已经没有了(实测
|
|
382
|
+
* `ls-files` 输出为空),status 却照样报 `1 D.`、基准侧也还在。只认 index 会把
|
|
383
|
+
* 它误判成未跟踪、掉进读磁盘那条路,以「文件不存在」告终;
|
|
384
|
+
* 2. **是不是二进制**。已跟踪那一侧一律以 git 自己的判定为准(含 `.gitattributes`),
|
|
385
|
+
* 比 NUL 探测准确(§5.2),形态是 `-\t-\t<路径>`;
|
|
386
|
+
* 3. **改了多少行**。行数阈值那一路的判据。
|
|
387
|
+
*
|
|
388
|
+
* 重命名同样要传两个路径 + `-M`:只传新路径时 git 无法配对,numstat 会把它算成
|
|
389
|
+
* 一个全新增文件(行数因此是整份文件),与补丁那一侧的退化是同一回事(§5.2)。
|
|
390
|
+
*/
|
|
391
|
+
async function readNumstat(root, base, path, oldPath) {
|
|
392
|
+
/**
|
|
393
|
+
* **一次查询可以回不止一条记录**,所以必须按路径挑,不能取 `[0]`:
|
|
394
|
+
*
|
|
395
|
+
* 传了两个路径而 git **配不上对**时(`git mv` 之后把内容全部重写、留在工作区不
|
|
396
|
+
* `git add` —— status 照报 `R100`,所以 `oldPath` 是有的,见 §10),它会拆成
|
|
397
|
+
* 「删掉旧的」+「新增新的」两条,按路径排序。取 `[0]` 就是掷硬币:实测那条
|
|
398
|
+
* 60,000 行的改名里,排在前面的是旧文件那条 100 行的删除,于是行数闸放它过去,
|
|
399
|
+
* 一份 6 万行的补丁照旧发给浏览器 —— 正是本阶段要防的那次冻结。二进制同理,
|
|
400
|
+
* 挑错记录会让一个文本文件被报成 binary(或反过来)。
|
|
401
|
+
*
|
|
402
|
+
* 挑中之后**合并**而不是取一条:那份补丁里两个文件都在,前端要渲染的是两者之和。
|
|
403
|
+
*/
|
|
404
|
+
const mine = parseNumstat(await runGitStrict(oldPath ? [
|
|
405
|
+
"diff",
|
|
406
|
+
base,
|
|
407
|
+
"--numstat",
|
|
408
|
+
"-z",
|
|
409
|
+
"-M",
|
|
410
|
+
"--",
|
|
411
|
+
path,
|
|
412
|
+
oldPath
|
|
413
|
+
] : [
|
|
414
|
+
"diff",
|
|
415
|
+
base,
|
|
416
|
+
"--numstat",
|
|
417
|
+
"-z",
|
|
418
|
+
"--",
|
|
419
|
+
path
|
|
420
|
+
], root)).filter((record) => record.path === path || oldPath !== void 0 && record.path === oldPath);
|
|
421
|
+
if (mine.length === 0) return null;
|
|
422
|
+
return {
|
|
423
|
+
binary: mine.some((record) => record.binary),
|
|
424
|
+
lines: mine.reduce((total, record) => total + record.lines, 0)
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
/** 工作区里这个文件的字节数;文件不在(删除)就是 `null`。 */
|
|
428
|
+
async function worktreeSize(abs) {
|
|
429
|
+
try {
|
|
430
|
+
return (await lstat(abs)).size;
|
|
431
|
+
} catch {
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
/**
|
|
436
|
+
* 已跟踪文件的补丁,以及三道拒绝(§5.2 / §5.12)。
|
|
437
|
+
*
|
|
438
|
+
* **已跟踪那一侧卡的是「补丁多大」,不是「文件多大」**:文件体积只对未跟踪那条路
|
|
439
|
+
* 成立(整份文件就是补丁),而已跟踪文件的补丁只包含改动与上下文 —— 按文件体积拒绝,
|
|
440
|
+
* 一个 6MB 的数据文件改一行就再也看不了,而那是 agent 最常见的输出之一。反过来,
|
|
441
|
+
* 行数也**不能**替代:一个「一行 6MB」的文件 numstat 只报 1 行,行数闸对它毫无作用。
|
|
442
|
+
* 两者都量不到的东西正是字节,所以那一闸只能由取补丁那次调用自己带着上限去撞。
|
|
443
|
+
*
|
|
444
|
+
* 顺序:二进制(numstat)→ 行数(numstat)→ 取补丁并卡字节。二进制先答是因为它比
|
|
445
|
+
* 「太大」更具体,一个 8MB 的 PNG 说「文件过大」等于把原因说错了;行数排在取补丁之前,
|
|
446
|
+
* 是因为它不用付出取补丁的代价就能拦下 5 万行以上的改动。
|
|
447
|
+
*
|
|
448
|
+
* `size` 只用于**说话**(告诉用户这文件多大),不再参与判定,所以**只在要拒绝的那两条
|
|
449
|
+
* 分支上才去 `lstat`**:正常那条路(绝大多数请求)一次系统调用都不欠。文件已被删除时
|
|
450
|
+
* 取不到,给 0,前端据此不显示体积(§5.12)。
|
|
451
|
+
*
|
|
452
|
+
* **重命名条目必须同时传新旧两个路径**:只传新路径时 git 看不到另一侧、无法配对,
|
|
453
|
+
* 会把重命名退化成一个全新增文件(已实测,spec §5.2),「重命名识别并标注」随之落空。
|
|
454
|
+
* 两个路径都来自 status 的 `2 ` 记录,无需额外查询。
|
|
455
|
+
*/
|
|
456
|
+
async function trackedDiff(root, base, path, abs, oldPath, stat) {
|
|
457
|
+
if (stat?.binary) return { kind: "binary" };
|
|
458
|
+
if (stat && stat.lines > MAX_LINES) return {
|
|
459
|
+
kind: "too-large",
|
|
460
|
+
size: await worktreeSize(abs) ?? 0,
|
|
461
|
+
reason: "lines"
|
|
462
|
+
};
|
|
463
|
+
const args = oldPath ? [
|
|
464
|
+
"diff",
|
|
465
|
+
base,
|
|
466
|
+
"-M",
|
|
467
|
+
"--",
|
|
468
|
+
path,
|
|
469
|
+
oldPath
|
|
470
|
+
] : [
|
|
471
|
+
"diff",
|
|
472
|
+
base,
|
|
473
|
+
"--",
|
|
474
|
+
path
|
|
475
|
+
];
|
|
476
|
+
let result;
|
|
477
|
+
try {
|
|
478
|
+
result = await runGit(args, root, { maxStdoutBytes: MAX_BYTES });
|
|
479
|
+
} catch (cause) {
|
|
480
|
+
if (cause instanceof GitError && cause.kind === "overflow") return {
|
|
481
|
+
kind: "too-large",
|
|
482
|
+
size: await worktreeSize(abs) ?? 0,
|
|
483
|
+
reason: "size"
|
|
484
|
+
};
|
|
485
|
+
throw cause;
|
|
486
|
+
}
|
|
487
|
+
if (result.code !== 0) throw new DiffRequestError("not-found", "no diff available for this path");
|
|
488
|
+
return {
|
|
489
|
+
kind: "text",
|
|
490
|
+
patch: result.stdout
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
/** unified diff 的头四行。未跟踪的一切都是「新增」,左侧恒为 /dev/null。 */
|
|
494
|
+
function newFileHeader(path, mode) {
|
|
495
|
+
return [
|
|
496
|
+
`diff --git a/${path} b/${path}`,
|
|
497
|
+
`new file mode ${mode}`,
|
|
498
|
+
"--- /dev/null",
|
|
499
|
+
`+++ b/${path}`
|
|
500
|
+
];
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* 未跟踪文件的补丁:**直接读文件内容手工构造 unified diff**。
|
|
504
|
+
*
|
|
505
|
+
* 不用 `git diff --no-index` —— 它依赖 `/dev/null` 作为对比端,Windows 上不可移植
|
|
506
|
+
* (spec §5.2 / §10)。
|
|
507
|
+
*/
|
|
508
|
+
async function untrackedDiff(root, path) {
|
|
509
|
+
const abs = resolveInRepo(root, path);
|
|
510
|
+
let info;
|
|
511
|
+
try {
|
|
512
|
+
info = await lstat(abs);
|
|
513
|
+
} catch {
|
|
514
|
+
throw new DiffRequestError("not-found", "file no longer exists");
|
|
515
|
+
}
|
|
516
|
+
if (info.isSymbolicLink()) {
|
|
517
|
+
const target = await readlink(abs);
|
|
518
|
+
return {
|
|
519
|
+
kind: "untracked-text",
|
|
520
|
+
patch: `${[
|
|
521
|
+
...newFileHeader(path, "120000"),
|
|
522
|
+
"@@ -0,0 +1 @@",
|
|
523
|
+
`+${target}`,
|
|
524
|
+
"\"
|
|
525
|
+
].join("\n")}\n`
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
if (!info.isFile()) throw new DiffRequestError("invalid-path", "not a regular file");
|
|
529
|
+
if (info.size > MAX_BYTES) return {
|
|
530
|
+
kind: "too-large",
|
|
531
|
+
size: info.size,
|
|
532
|
+
reason: "size"
|
|
533
|
+
};
|
|
534
|
+
const buffer = await readFile(abs);
|
|
535
|
+
if (buffer.includes(0)) return { kind: "binary" };
|
|
536
|
+
const text = buffer.toString("utf8");
|
|
537
|
+
const endsWithNewline = text.endsWith("\n");
|
|
538
|
+
const lines = text === "" ? [] : text.split("\n");
|
|
539
|
+
if (endsWithNewline) lines.pop();
|
|
540
|
+
if (lines.length > MAX_LINES) return {
|
|
541
|
+
kind: "too-large",
|
|
542
|
+
size: info.size,
|
|
543
|
+
reason: "lines"
|
|
544
|
+
};
|
|
545
|
+
const head = newFileHeader(path, info.mode & 73 ? "100755" : "100644");
|
|
546
|
+
if (lines.length === 0) return {
|
|
547
|
+
kind: "untracked-text",
|
|
548
|
+
patch: `${head.join("\n")}\n`
|
|
549
|
+
};
|
|
550
|
+
const body = lines.map((line) => `+${line}`);
|
|
551
|
+
if (!endsWithNewline) body.push("\");
|
|
552
|
+
return {
|
|
553
|
+
kind: "untracked-text",
|
|
554
|
+
patch: `${[
|
|
555
|
+
...head,
|
|
556
|
+
`@@ -0,0 +1,${lines.length} @@`,
|
|
557
|
+
...body
|
|
558
|
+
].join("\n")}\n`
|
|
559
|
+
};
|
|
560
|
+
}
|
|
561
|
+
async function readDiff(root, query) {
|
|
562
|
+
const abs = resolveInRepo(root, query.path);
|
|
563
|
+
if (query.oldPath) resolveInRepo(root, query.oldPath);
|
|
564
|
+
const [base, listed] = await Promise.all([resolveDiffBase(root), inIndex(root, query.path)]);
|
|
565
|
+
const stat = await readNumstat(root, base, query.path, query.oldPath);
|
|
566
|
+
if (listed || stat !== null) return trackedDiff(root, base, query.path, abs, query.oldPath, stat);
|
|
567
|
+
return untrackedDiff(root, query.path);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
//#endregion
|
|
571
|
+
//#region src/server/git/operation.ts
|
|
572
|
+
/**
|
|
573
|
+
* 判据表,**按序取第一个命中**(spec §5.3)。
|
|
574
|
+
*
|
|
575
|
+
* 两处顺序不是随手排的:
|
|
576
|
+
*
|
|
577
|
+
* - **rebase 排在 merge 之前**。rebase 冲突停下时 git 目录里同时躺着 `rebase-merge/`
|
|
578
|
+
* 与 `MERGE_MSG` / `AUTO_MERGE`(已实测,§10),而用户处在的是 rebase —— 反过来
|
|
579
|
+
* 排会把它标成「合并中」,页面上看不出任何异样,只是说错了用户在干什么。
|
|
580
|
+
* `MERGE_HEAD` 本身在那种情形下不出现,所以这一条更像是给后来者的防线
|
|
581
|
+
* - **`rebase-apply/rebasing` 排在 `rebase-apply` 之前**。`git am` 与 `git rebase --apply`
|
|
582
|
+
* 共用 `rebase-apply/` 这一个目录,里面有没有 `rebasing` 是区分两者的唯一办法;
|
|
583
|
+
* 合并成一个标注等于对着一个正在 `git am` 的用户说他在 rebase
|
|
584
|
+
*/
|
|
585
|
+
const PROBES = [
|
|
586
|
+
{
|
|
587
|
+
path: "rebase-merge",
|
|
588
|
+
operation: "rebase"
|
|
589
|
+
},
|
|
590
|
+
{
|
|
591
|
+
path: "rebase-apply/rebasing",
|
|
592
|
+
operation: "rebase"
|
|
593
|
+
},
|
|
594
|
+
{
|
|
595
|
+
path: "rebase-apply",
|
|
596
|
+
operation: "am"
|
|
597
|
+
},
|
|
598
|
+
{
|
|
599
|
+
path: "MERGE_HEAD",
|
|
600
|
+
operation: "merge"
|
|
601
|
+
},
|
|
602
|
+
{
|
|
603
|
+
path: "CHERRY_PICK_HEAD",
|
|
604
|
+
operation: "cherry-pick"
|
|
605
|
+
},
|
|
606
|
+
{
|
|
607
|
+
path: "REVERT_HEAD",
|
|
608
|
+
operation: "revert"
|
|
609
|
+
},
|
|
610
|
+
{
|
|
611
|
+
path: "BISECT_LOG",
|
|
612
|
+
operation: "bisect"
|
|
613
|
+
}
|
|
614
|
+
];
|
|
615
|
+
/** 存在与否,不区分文件还是目录 —— 上表里两种都有,而判据只是「在不在」。 */
|
|
616
|
+
function exists(path) {
|
|
617
|
+
return access(path).then(() => true, () => false);
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* 当前进行中的多步操作;没有则 `undefined`。
|
|
621
|
+
*
|
|
622
|
+
* **传进来的必须是 `git rev-parse --git-dir` 的返回值,不能自己拼 `<root>/.git`**
|
|
623
|
+
* (§5.3):linked worktree 下 `.git` 是个文件、真正的 git 目录在
|
|
624
|
+
* `<主仓库>/.git/worktrees/<名>`,submodule 在 `<父仓库>/.git/modules/<路径>`
|
|
625
|
+
* (均已实测,§10)。拼出来的路径在那两种仓库里永远读不到 —— 于是永远标不出操作,
|
|
626
|
+
* 而它不报错。`RepoInfo.gitDir` 已经是这个值,调用方照传即可。
|
|
627
|
+
*
|
|
628
|
+
* 七个探针**并发发**再按上表定序:顺序判据只有一处(上面那张表),而不是散落在
|
|
629
|
+
* 一串 `await` 的先后里 —— 后者调换两行就静默换了语义。每个探针直接产出**答案还是
|
|
630
|
+
* undefined**,于是取第一个命中就是一次普通的 `find`,不必再拿下标去另一个数组里
|
|
631
|
+
* 对号入座 —— 那种写法会让「顺序对不对」多出一个不在表里的依赖。
|
|
632
|
+
*/
|
|
633
|
+
async function readOperation(gitDir) {
|
|
634
|
+
return (await Promise.all(PROBES.map(async (probe) => await exists(join(gitDir, probe.path)) ? probe.operation : void 0))).find((operation) => operation !== void 0);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
//#endregion
|
|
638
|
+
//#region src/server/git/status.ts
|
|
639
|
+
/**
|
|
640
|
+
* 文件列表与分支状态的**唯一**数据源,一次调用同时拿到文件状态、暂存/未暂存双状态位、
|
|
641
|
+
* 重命名信息与分支 / ahead-behind。
|
|
642
|
+
*
|
|
643
|
+
* 导出成常量是为了让 S3b 的降级轮询**复用逐字相同的这一条**,而不是各写各的:
|
|
644
|
+
* 漏掉 `-uall` 的后果是静默的 —— git 会把未跟踪目录折叠成一行 `dir/`,于是在一个
|
|
645
|
+
* 已存在的未跟踪目录里新增文件根本不改变输出,轮询判定「无变化」、页面不刷新,
|
|
646
|
+
* 而那正是 agent 边跑边生成文件时最常见的形态(spec §5.7 / §10)。
|
|
647
|
+
*
|
|
648
|
+
* 两个参数都不能省:`-uall` 见上;`-z` 是因为不加它 git 会对含非 ASCII 字符、空格、
|
|
649
|
+
* 引号的路径做 C 风格转义并加引号(已实测)。
|
|
650
|
+
*/
|
|
651
|
+
const STATUS_ARGS = [
|
|
652
|
+
"status",
|
|
653
|
+
"--porcelain=v2",
|
|
654
|
+
"--branch",
|
|
655
|
+
"-uall",
|
|
656
|
+
"-z"
|
|
657
|
+
];
|
|
658
|
+
const STATUS_CODES = /* @__PURE__ */ new Set([
|
|
659
|
+
".",
|
|
660
|
+
"M",
|
|
661
|
+
"T",
|
|
662
|
+
"A",
|
|
663
|
+
"D",
|
|
664
|
+
"R",
|
|
665
|
+
"C",
|
|
666
|
+
"U"
|
|
667
|
+
]);
|
|
668
|
+
function asStatusCode(ch) {
|
|
669
|
+
return ch && STATUS_CODES.has(ch) ? ch : ".";
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* 切出记录开头的 `n` 个空格分隔字段,剩下的整段就是路径。
|
|
673
|
+
*
|
|
674
|
+
* 不能整条 `split(' ')` —— 路径本身可以含空格,而 `-z` 只把**记录**分隔成 NUL 段,
|
|
675
|
+
* 段内仍是空格分隔。
|
|
676
|
+
*/
|
|
677
|
+
function splitLeading(rec, n) {
|
|
678
|
+
const fields = [];
|
|
679
|
+
let start = 0;
|
|
680
|
+
for (let k = 0; k < n; k += 1) {
|
|
681
|
+
const sp = rec.indexOf(" ", start);
|
|
682
|
+
if (sp === -1) return null;
|
|
683
|
+
fields.push(rec.slice(start, sp));
|
|
684
|
+
start = sp + 1;
|
|
685
|
+
}
|
|
686
|
+
const rest = rec.slice(start);
|
|
687
|
+
return rest ? [fields, rest] : null;
|
|
688
|
+
}
|
|
689
|
+
/** `R100` / `C75` → 100 / 75。 */
|
|
690
|
+
function parseScore(field) {
|
|
691
|
+
const m = field ? /^[RC](\d+)$/.exec(field) : null;
|
|
692
|
+
return m?.[1] ? Number(m[1]) : null;
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* 解析 `-z` 输出。
|
|
696
|
+
*
|
|
697
|
+
* **NUL 在重命名记录里既是记录分隔符、又是字段分隔符**:`2 ` 记录的格式是
|
|
698
|
+
* `2 <XY> ... R<score> <新路径>\0<旧路径>`,一条记录占**两个** NUL 段(已实测)。
|
|
699
|
+
* 因此不能无状态地按 NUL 平铺切分,遇到 `2 ` 必须额外吞掉下一段作为旧路径。
|
|
700
|
+
*/
|
|
701
|
+
function parseStatus(raw) {
|
|
702
|
+
const segments = raw.split("\0");
|
|
703
|
+
const files = [];
|
|
704
|
+
let head = "";
|
|
705
|
+
let detached = false;
|
|
706
|
+
let upstream = null;
|
|
707
|
+
for (let i = 0; i < segments.length; i += 1) {
|
|
708
|
+
const rec = segments[i];
|
|
709
|
+
if (!rec) continue;
|
|
710
|
+
switch (rec[0]) {
|
|
711
|
+
case "#": {
|
|
712
|
+
const sp = rec.indexOf(" ", 2);
|
|
713
|
+
const key = sp === -1 ? rec.slice(2) : rec.slice(2, sp);
|
|
714
|
+
const value = sp === -1 ? "" : rec.slice(sp + 1);
|
|
715
|
+
if (key === "branch.head") {
|
|
716
|
+
head = value;
|
|
717
|
+
detached = value === "(detached)";
|
|
718
|
+
} else if (key === "branch.ab") {
|
|
719
|
+
const m = /^\+(\d+)\s+-(\d+)$/.exec(value.trim());
|
|
720
|
+
if (m?.[1] && m[2]) upstream = {
|
|
721
|
+
ahead: Number(m[1]),
|
|
722
|
+
behind: Number(m[2])
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
break;
|
|
726
|
+
}
|
|
727
|
+
case "1": {
|
|
728
|
+
const split = splitLeading(rec, 8);
|
|
729
|
+
if (!split) break;
|
|
730
|
+
const [fields, path] = split;
|
|
731
|
+
files.push({
|
|
732
|
+
path,
|
|
733
|
+
kind: "tracked",
|
|
734
|
+
staged: asStatusCode(fields[1]?.[0]),
|
|
735
|
+
unstaged: asStatusCode(fields[1]?.[1])
|
|
736
|
+
});
|
|
737
|
+
break;
|
|
738
|
+
}
|
|
739
|
+
case "2": {
|
|
740
|
+
const split = splitLeading(rec, 9);
|
|
741
|
+
const oldPath = segments[i + 1] ?? "";
|
|
742
|
+
i += 1;
|
|
743
|
+
if (!split) break;
|
|
744
|
+
const [fields, path] = split;
|
|
745
|
+
const score = parseScore(fields[8]);
|
|
746
|
+
files.push({
|
|
747
|
+
path,
|
|
748
|
+
oldPath,
|
|
749
|
+
kind: "tracked",
|
|
750
|
+
staged: asStatusCode(fields[1]?.[0]),
|
|
751
|
+
unstaged: asStatusCode(fields[1]?.[1]),
|
|
752
|
+
...score === null ? {} : { renameScore: score }
|
|
753
|
+
});
|
|
754
|
+
break;
|
|
755
|
+
}
|
|
756
|
+
case "u": {
|
|
757
|
+
const split = splitLeading(rec, 10);
|
|
758
|
+
if (!split) break;
|
|
759
|
+
const [fields, path] = split;
|
|
760
|
+
files.push({
|
|
761
|
+
path,
|
|
762
|
+
kind: "tracked",
|
|
763
|
+
staged: asStatusCode(fields[1]?.[0]),
|
|
764
|
+
unstaged: asStatusCode(fields[1]?.[1]),
|
|
765
|
+
conflicted: true
|
|
766
|
+
});
|
|
767
|
+
break;
|
|
768
|
+
}
|
|
769
|
+
case "?": files.push({
|
|
770
|
+
path: rec.slice(2),
|
|
771
|
+
kind: "untracked",
|
|
772
|
+
staged: ".",
|
|
773
|
+
unstaged: "?"
|
|
774
|
+
});
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
return {
|
|
778
|
+
branch: {
|
|
779
|
+
head,
|
|
780
|
+
detached,
|
|
781
|
+
upstream
|
|
782
|
+
},
|
|
783
|
+
files
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* 主查询的**原始输出**,不解析。
|
|
788
|
+
*
|
|
789
|
+
* 存在的唯一理由是 §5.7 的降级轮询:它只需要回答「变没变」,而逐字节比对原始输出
|
|
790
|
+
* 既是最省的判据,也让「轮询与主查询是同一条命令」成为**构造上的事实**而不是
|
|
791
|
+
* 两处各自维护的巧合 —— 后者漏个 `-uall` 不报错,只是在一个已存在的未跟踪目录里
|
|
792
|
+
* 新增文件时页面不刷新(§5.2 / §5.7 的红线)。
|
|
793
|
+
*
|
|
794
|
+
* 比对解析后的结构也做得到,但那要么写一份深比较、要么 `JSON.stringify` 一遍,
|
|
795
|
+
* 而两者都会随协议类型增删字段而静默改变灵敏度。
|
|
796
|
+
*/
|
|
797
|
+
async function readStatusRaw(root) {
|
|
798
|
+
return runGitStrict(STATUS_ARGS, root);
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* 变更列表 + 分支状态。
|
|
802
|
+
*
|
|
803
|
+
* 取 `RepoInfo` 而不是两个字符串:进行中的多步操作只能从 **git 目录**下的状态文件读
|
|
804
|
+
* (§5.3),而它和工作区根是两条不同的路径 —— linked worktree 与 submodule 下差得很远。
|
|
805
|
+
* 两个同类型参数并排放着,调换顺序不会报错,只会让 `operation` 从此恒为空。
|
|
806
|
+
*
|
|
807
|
+
* 两件事并发:`git status` 是一次子进程,`readOperation` 是七个 `access`,互不依赖。
|
|
808
|
+
*/
|
|
809
|
+
async function readStatus(repo) {
|
|
810
|
+
const [raw, operation] = await Promise.all([readStatusRaw(repo.root), readOperation(repo.gitDir)]);
|
|
811
|
+
const status = parseStatus(raw);
|
|
812
|
+
if (operation !== void 0) status.branch.operation = operation;
|
|
813
|
+
return status;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
//#endregion
|
|
817
|
+
//#region src/server/watch/tier.ts
|
|
818
|
+
/** `fs.watch` 的 `ignore` 选项自此版本可用(spec §5.7)。 */
|
|
819
|
+
const IGNORE_SINCE = {
|
|
820
|
+
major: 24,
|
|
821
|
+
minor: 14,
|
|
822
|
+
patch: 0
|
|
823
|
+
};
|
|
824
|
+
/**
|
|
825
|
+
* 强制指定档位的**内部**环境变量(spec §7:S3b1 的首个交付物)。
|
|
826
|
+
*
|
|
827
|
+
* 没有它,S3b2 的六条档位验收项在单机上一条都无从自查 —— 一台机器只有一个 Node
|
|
828
|
+
* 版本、一个平台,而三档正是按这两者分的。它不是给用户的开关,README 不写它。
|
|
829
|
+
*/
|
|
830
|
+
const TIER_ENV = "DIFFTAB_WATCH_TIER";
|
|
831
|
+
/** 档位环境变量的取值不合法。由 main() 收成一句话友好报错。 */
|
|
832
|
+
var WatchTierError = class extends Error {
|
|
833
|
+
constructor(message) {
|
|
834
|
+
super(message);
|
|
835
|
+
this.name = "WatchTierError";
|
|
836
|
+
}
|
|
837
|
+
};
|
|
838
|
+
/**
|
|
839
|
+
* `24.14.1` / `26.0.0-nightly20260101` → 三元组 + 是否带预发布标签。
|
|
840
|
+
*
|
|
841
|
+
* 解析不出来返回 null,调用方按「没有 `ignore`」处理 —— 见 `supportsIgnoreOption`
|
|
842
|
+
* 里那段关于误判方向的说明。
|
|
843
|
+
*/
|
|
844
|
+
function parseNodeVersion(version) {
|
|
845
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)(-.*)?$/.exec(version.trim());
|
|
846
|
+
if (!m) return null;
|
|
847
|
+
return {
|
|
848
|
+
major: Number(m[1]),
|
|
849
|
+
minor: Number(m[2]),
|
|
850
|
+
patch: Number(m[3]),
|
|
851
|
+
prerelease: m[4] !== void 0
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
/**
|
|
855
|
+
* 当前 Node 是否具备 `fs.watch` 的 `ignore` 选项。
|
|
856
|
+
*
|
|
857
|
+
* **解析失败一律按「没有」处理**,方向是刻意的:误判成没有,代价是 Linux 上退化为
|
|
858
|
+
* 1.5s 轮询(功能完整,只是不即时);误判成有,代价是上面那条注释里的 ENOSPC。
|
|
859
|
+
* 两种误判的代价差着一个数量级,判据不确定时只能倒向便宜的那一侧。
|
|
860
|
+
*
|
|
861
|
+
* 预发布标签按 semver 的规矩算作**低于**同版本正式版(`24.14.0-rc.1 < 24.14.0`):
|
|
862
|
+
* 那正是选项刚合入、行为还可能变的窗口。
|
|
863
|
+
*/
|
|
864
|
+
function supportsIgnoreOption(nodeVersion) {
|
|
865
|
+
const v = parseNodeVersion(nodeVersion);
|
|
866
|
+
if (!v) return false;
|
|
867
|
+
if (v.major !== IGNORE_SINCE.major) return v.major > IGNORE_SINCE.major;
|
|
868
|
+
if (v.minor !== IGNORE_SINCE.minor) return v.minor > IGNORE_SINCE.minor;
|
|
869
|
+
if (v.patch !== IGNORE_SINCE.patch) return v.patch > IGNORE_SINCE.patch;
|
|
870
|
+
return !v.prerelease;
|
|
871
|
+
}
|
|
872
|
+
/**
|
|
873
|
+
* 档位判定(spec §5.7 的三档表)。
|
|
874
|
+
*
|
|
875
|
+
* - **A**:有 `ignore`,三端通用 —— Linux 上是注册前跳过,正是配额问题的官方解法
|
|
876
|
+
* - **B**:没有 `ignore`,但在 macOS / Windows 上走原生 FSEvents / `ReadDirectoryChangesW`,
|
|
877
|
+
* 单句柄监听整棵树,本就没有配额问题,回调里自己过滤即可
|
|
878
|
+
* - **C**:没有 `ignore` 且在 Linux 上 —— **不建递归 watch**,工作区改动走轮询
|
|
879
|
+
*/
|
|
880
|
+
function detectTier(nodeVersion, platform) {
|
|
881
|
+
if (supportsIgnoreOption(nodeVersion)) return "A";
|
|
882
|
+
return platform === "linux" ? "C" : "B";
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* 实际生效的档位:环境变量优先,否则按运行时判定。
|
|
886
|
+
*
|
|
887
|
+
* **取值不合法时抛错而不是退回自动判定**:`DIFFTAB_WATCH_TIER=b` 那种手滑,退回
|
|
888
|
+
* 自动判定的话在本机上多半照样给出 B 档,于是「我验过 B 档了」这个结论建立在
|
|
889
|
+
* 一次根本没生效的强制指定上 —— 而 S3b2 的六条验收项全都压在这个变量上。
|
|
890
|
+
*/
|
|
891
|
+
function forcedTier(env) {
|
|
892
|
+
const forced = env[TIER_ENV];
|
|
893
|
+
if (forced === void 0 || forced.trim() === "") return null;
|
|
894
|
+
const tier = forced.trim().toUpperCase();
|
|
895
|
+
if (tier !== "A" && tier !== "B" && tier !== "C") throw new WatchTierError(`${TIER_ENV} must be one of A, B, C — got ${JSON.stringify(forced)}.`);
|
|
896
|
+
return tier;
|
|
897
|
+
}
|
|
898
|
+
function resolveTier(env = process.env, nodeVersion = process.versions.node, platform = process.platform) {
|
|
899
|
+
return forcedTier(env) ?? detectTier(nodeVersion, platform);
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* 该档位下**工作区通路**的既定形态(spec §5.12 的 `WatchState.mode`)。
|
|
903
|
+
*
|
|
904
|
+
* C 档一开始就以轮询为工作区通路,A / B 档则是原生监听。**它只是「还没起监听时
|
|
905
|
+
* 答什么」**:监听懒起(见 http/server.ts),起了之后真实取值由 `WatchHandle.mode`
|
|
906
|
+
* 给 —— A / B 档运行中落到轮询兜底时,那一侧会翻成 `polling`。
|
|
907
|
+
*/
|
|
908
|
+
function initialMode(tier) {
|
|
909
|
+
return tier === "C" ? "polling" : "native";
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* 强制指定 A 档、但这个 Node 根本没有 `ignore` 时的一句提醒(没有则返回 null)。
|
|
913
|
+
*
|
|
914
|
+
* **不是报错**:「三档均可通过内部环境变量强制指定」是 §6 已经勾掉的验收项,
|
|
915
|
+
* 在 Node 22 上拒绝启动会把它推翻,而 macOS / Windows 上强制 A 档去看别的行为
|
|
916
|
+
* 也是正当用法。但沉默同样不行 —— Node 对未知选项是**静默忽略**,于是这次
|
|
917
|
+
* 「A 档」跑的是一个**没有任何过滤的递归 watch**:在 Linux 上那正是耗尽
|
|
918
|
+
* `fs.inotify.max_user_watches` 的那条路,而结论会写成「我验过 A 档了」。
|
|
919
|
+
*/
|
|
920
|
+
function forcedTierWarning(env = process.env, nodeVersion = process.versions.node) {
|
|
921
|
+
if (forcedTier(env) !== "A" || supportsIgnoreOption(nodeVersion)) return null;
|
|
922
|
+
const since = `${IGNORE_SINCE.major}.${IGNORE_SINCE.minor}.${IGNORE_SINCE.patch}`;
|
|
923
|
+
return `${TIER_ENV}=A on Node ${nodeVersion}: fs.watch has no "ignore" option before ${since}, so the recursive watch runs unfiltered — this is not tier A.`;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
//#endregion
|
|
927
|
+
//#region src/server/watch/ignore.ts
|
|
928
|
+
/**
|
|
929
|
+
* 路径任一段命中即忽略。
|
|
930
|
+
*
|
|
931
|
+
* `.git` 在集合里**与档位无关**:工作区那条递归 watch 绝不能进 `.git`(一次 gc 就是
|
|
932
|
+
* 几万个条目),`.git` 侧另有目录级非递归 watch 盯着(见 watcher.ts)。
|
|
933
|
+
*/
|
|
934
|
+
const IGNORE_NAMES = /* @__PURE__ */ new Set([
|
|
935
|
+
"node_modules",
|
|
936
|
+
".git",
|
|
937
|
+
"dist",
|
|
938
|
+
"target",
|
|
939
|
+
".next",
|
|
940
|
+
"build"
|
|
941
|
+
]);
|
|
942
|
+
/** 路径分隔符两种都切:Windows 的原生 watcher 给的是反斜杠分隔的相对路径。 */
|
|
943
|
+
const SEPARATOR = /[\\/]/;
|
|
944
|
+
/**
|
|
945
|
+
* 大小写归一,对齐 `ignore` 内部的 `nocase: isWindows || isMacOS`。**不导出**:
|
|
946
|
+
* 它只有 `createIsIgnored` 一个消费者,而多一个入口就多一份会跟着漂的契约。
|
|
947
|
+
*
|
|
948
|
+
* Linux 上**原样返回**:那里的文件系统区分大小写,`Node_Modules` 与 `node_modules`
|
|
949
|
+
* 是两个不同的目录,归一等于把一个用户真的想看的目录悄悄屏蔽掉。
|
|
950
|
+
*/
|
|
951
|
+
function caseFold(segment, platform) {
|
|
952
|
+
return platform === "win32" || platform === "darwin" ? segment.toLowerCase() : segment;
|
|
953
|
+
}
|
|
954
|
+
/**
|
|
955
|
+
* 造一个绑定平台的匹配器。**平台可注入是为了让三端的归一行为都能在一台机器上验**
|
|
956
|
+
* —— 与 `DIFFTAB_WATCH_TIER` 同一个理由(§5.7)。
|
|
957
|
+
*
|
|
958
|
+
* 收到的是**相对被监听目录的路径**(A 档 Linux 侧是遍历到的条目,A 档 macOS/Windows
|
|
959
|
+
* 与 B 档是事件路径),不是绝对路径 —— 传绝对路径进来也能工作,只是仓库自身若躺在
|
|
960
|
+
* 某个叫 `build` 的目录下就会整片被忽略,所以调用方不该那么用。
|
|
961
|
+
*/
|
|
962
|
+
function createIsIgnored(platform = process.platform) {
|
|
963
|
+
return (path) => path.split(SEPARATOR).some((segment) => IGNORE_NAMES.has(caseFold(segment, platform)));
|
|
964
|
+
}
|
|
965
|
+
/** 当前平台的那一份。A 档传给 `ignore`、B 档在回调最前面调,是**同一个函数**。 */
|
|
966
|
+
const isIgnored = createIsIgnored();
|
|
967
|
+
|
|
968
|
+
//#endregion
|
|
969
|
+
//#region src/server/watch/watcher.ts
|
|
970
|
+
/**
|
|
971
|
+
* 事件合并窗口(spec §5.7 建议 100-200ms)。
|
|
972
|
+
*
|
|
973
|
+
* **在 Linux 上这是必需项而非优化项**:用户态递归实现初次遍历目录树时会对遍历到的
|
|
974
|
+
* 每个条目 emit 一次事件,启动瞬间即产生一波与实际变更无关的风暴。`.git` 侧同样需要:
|
|
975
|
+
* 一条 `git commit` 会连着写 index.lock、index、COMMIT_EDITMSG、HEAD 的 reflog、
|
|
976
|
+
* refs/heads/<分支>,五六个事件是常态。
|
|
977
|
+
*/
|
|
978
|
+
const DEBOUNCE_MS = 150;
|
|
979
|
+
/** 降级轮询的周期(spec §5.7「1.5s 轮询」)。C 档的工作区通路也是它。 */
|
|
980
|
+
const POLL_MS = 1500;
|
|
981
|
+
/**
|
|
982
|
+
* **原生档(A / B)的低频安全轮询周期**(spec §5.7)。
|
|
983
|
+
*
|
|
984
|
+
* 它补的是一个没有任何信号的缺口:Linux 上 inotify 配额在**遍历途中**耗尽时,Node
|
|
985
|
+
* 一次都不 emit(2026-08-18 实测,推翻了此前的源码推断,见 §10)—— 没轮上注册的那些
|
|
986
|
+
* 目录里,改一个**启动前就存在**的文件从此静默丢失,`mode` 还一直说 `native`。
|
|
987
|
+
* 原生监听少报了什么是没法从监听那一侧知道的,只有拿 status 输出本身去比才看得见。
|
|
988
|
+
*
|
|
989
|
+
* **取 30s 而不是 1.5s**:§6 要求「原生监听模式下空闲 CPU 接近零」,一次 status 几十
|
|
990
|
+
* 毫秒,30s 一拍的占空比是千分之几;代价是那个病态场景下最坏 30s 的滞后。
|
|
991
|
+
*/
|
|
992
|
+
const SAFETY_POLL_MS = 3e4;
|
|
993
|
+
/**
|
|
994
|
+
* `.git` 下需要盯的目录清单。
|
|
995
|
+
*
|
|
996
|
+
* **一律是目录,绝不是单个文件**:Linux / macOS 上 watch 绑的是 inode,而 git 写
|
|
997
|
+
* `HEAD` / `index` 走的是「写临时文件 + 原子 rename」,新文件是新 inode,对文件建的
|
|
998
|
+
* watch 从此静默失效(spec §5.7)。
|
|
999
|
+
*
|
|
1000
|
+
* **绝不递归、更绝不进 `objects`**:一次 gc 就是几万个条目,既是配额灾难,也会把
|
|
1001
|
+
* 与展示无关的写入变成事件风暴。清单之所以短得像不够用,是因为主力其实是 `gitDir`
|
|
1002
|
+
* 本身 —— 提交写 `index` 与 `COMMIT_EDITMSG`、切分支写 `HEAD`、fetch 写 `FETCH_HEAD`,
|
|
1003
|
+
* 全都直接落在它下面。`refs/` 那两条是补 `refs/heads/<分支>` 这种一层深的更新,
|
|
1004
|
+
* 属于锦上添花;`refs/heads/feature/x` 这类嵌套分支名照样只由 `index` 那一路兜住,
|
|
1005
|
+
* 而那一路本来就够。
|
|
1006
|
+
*/
|
|
1007
|
+
function gitWatchDirs(gitDir) {
|
|
1008
|
+
const dirs = [];
|
|
1009
|
+
for (const rel of [
|
|
1010
|
+
"",
|
|
1011
|
+
"refs",
|
|
1012
|
+
"refs/heads",
|
|
1013
|
+
"refs/remotes"
|
|
1014
|
+
]) {
|
|
1015
|
+
const dir = rel === "" ? gitDir : join(gitDir, ...rel.split("/"));
|
|
1016
|
+
try {
|
|
1017
|
+
if (statSync(dir).isDirectory()) dirs.push(dir);
|
|
1018
|
+
} catch {}
|
|
1019
|
+
}
|
|
1020
|
+
return dirs;
|
|
1021
|
+
}
|
|
1022
|
+
/**
|
|
1023
|
+
* 起监听。
|
|
1024
|
+
*
|
|
1025
|
+
* 关于「我们自己会不会把自己触发起来」:不会,而且这件事全靠封装层那条
|
|
1026
|
+
* `GIT_OPTIONAL_LOCKS=0`(§5.2 红线)。不设它的话 `git status` 会把 stat 缓存写回
|
|
1027
|
+
* `.git/index`,于是每次刷新都写一次 index、每次写 index 都触发一次刷新 —— 一个不报错、
|
|
1028
|
+
* 只是 CPU 常年 1% 的自激循环,而 status 的输出从头到尾都是对的。轮询那条路上这
|
|
1029
|
+
* 一点更要命:它每 1.5s 主动跑一次 status。
|
|
1030
|
+
*/
|
|
1031
|
+
function createWatcher(options) {
|
|
1032
|
+
const { gitDir, repoRoot, tier, onChange, pollStatus, onDegrade, debounceMs = 150, pollMs = POLL_MS, safetyPollMs = SAFETY_POLL_MS } = options;
|
|
1033
|
+
const gitWatchers = [];
|
|
1034
|
+
let workspaceWatcher = null;
|
|
1035
|
+
let timer = null;
|
|
1036
|
+
let pollTimer = null;
|
|
1037
|
+
let lastSnapshot = null;
|
|
1038
|
+
let polling = false;
|
|
1039
|
+
let pollLoopStarted = false;
|
|
1040
|
+
let pollInFlight = false;
|
|
1041
|
+
let closed = false;
|
|
1042
|
+
/**
|
|
1043
|
+
* **不是「每来一个事件就把窗口往后推」的那种 debounce**。
|
|
1044
|
+
*
|
|
1045
|
+
* 那一种在持续写入下会一直不触发 —— 而 agent 连着改几十个文件、跑一次装依赖,
|
|
1046
|
+
* 正是持续写入。这里是「第一个事件起一个窗口,窗口内的后续事件被它吞掉」,
|
|
1047
|
+
* 于是风暴期间也保证每 `debounceMs` 至少刷新一次。
|
|
1048
|
+
*
|
|
1049
|
+
* `.git` 侧、工作区侧与轮询共用这一个窗口(见文件头)。
|
|
1050
|
+
*/
|
|
1051
|
+
const schedule = () => {
|
|
1052
|
+
if (closed || timer !== null) return;
|
|
1053
|
+
timer = setTimeout(() => {
|
|
1054
|
+
timer = null;
|
|
1055
|
+
onChange();
|
|
1056
|
+
}, debounceMs);
|
|
1057
|
+
timer.unref();
|
|
1058
|
+
};
|
|
1059
|
+
/**
|
|
1060
|
+
* 轮询一拍:取一份快照,与上一份不同就当作一次变更。
|
|
1061
|
+
*
|
|
1062
|
+
* 用 `setTimeout` 链而不是 `setInterval`:探针是一次 git 子进程,大仓库上未必
|
|
1063
|
+
* 1.5s 内跑得完,`setInterval` 会让它们叠着跑。
|
|
1064
|
+
*
|
|
1065
|
+
* **首拍只建立基线,不触发刷新**(`lastSnapshot === null`):降级那一刻页面刚
|
|
1066
|
+
* 重取过,再推一次纯属白刷。探针失败(仓库正被 index.lock 挡住、git 一时不可用)
|
|
1067
|
+
* 也不终止轮询 —— 那是暂态,停下来就再也起不来了。
|
|
1068
|
+
*/
|
|
1069
|
+
const pollOnce = async () => {
|
|
1070
|
+
pollInFlight = true;
|
|
1071
|
+
try {
|
|
1072
|
+
const snapshot = await pollStatus();
|
|
1073
|
+
if (closed) {
|
|
1074
|
+
pollInFlight = false;
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
if (lastSnapshot !== null && snapshot !== lastSnapshot) schedule();
|
|
1078
|
+
lastSnapshot = snapshot;
|
|
1079
|
+
} catch {}
|
|
1080
|
+
pollInFlight = false;
|
|
1081
|
+
if (closed) return;
|
|
1082
|
+
armPoll();
|
|
1083
|
+
};
|
|
1084
|
+
/**
|
|
1085
|
+
* 排下一拍。**同一个循环的两个周期,不是两套机制**:原生档跑 `safetyPollMs`(30s)
|
|
1086
|
+
* 的安全轮询,降级之后自动收到 `pollMs`(1.5s)。两个定时器各排各的话,降级那一刻
|
|
1087
|
+
* 要记得把前一个停掉 —— 忘了不报错,只是从此每 30s 白跑一次 git。
|
|
1088
|
+
*
|
|
1089
|
+
* 每次都先清掉在等的那一拍:降级时正是靠这一下把 30s 的等待按新周期提前,否则
|
|
1090
|
+
* 「已降级」之后最长还要再等 30s 才有第一次轮询,而页面上标的已经是轮询了。
|
|
1091
|
+
*/
|
|
1092
|
+
const armPoll = () => {
|
|
1093
|
+
if (closed) return;
|
|
1094
|
+
if (pollTimer !== null) clearTimeout(pollTimer);
|
|
1095
|
+
pollTimer = setTimeout(() => void pollOnce(), polling ? pollMs : safetyPollMs);
|
|
1096
|
+
pollTimer.unref();
|
|
1097
|
+
};
|
|
1098
|
+
/** 起轮询循环。**只起一次** —— 起两次就是两条链各自排各自的定时器。 */
|
|
1099
|
+
const startPolling = () => {
|
|
1100
|
+
if (pollLoopStarted) return;
|
|
1101
|
+
pollLoopStarted = true;
|
|
1102
|
+
pollOnce();
|
|
1103
|
+
};
|
|
1104
|
+
/**
|
|
1105
|
+
* 转到轮询。**`cause` 为 null 表示这是该档位的既定形态,不是降级** —— C 档一开始
|
|
1106
|
+
* 就走这条路,没有任何东西坏掉,自然也不该上报。
|
|
1107
|
+
*
|
|
1108
|
+
* 只有一个入口(而不是「起轮询」+「降级」两个),是因为 `polling` 这个闩只该在
|
|
1109
|
+
* 一处合上:两个入口各带一份 `closed || polling` 判断时,「谁先谁后、谁的守卫
|
|
1110
|
+
* 拦住了谁」就成了调用点必须自己想清楚的事,而想错的症状是多推一次降级通知。
|
|
1111
|
+
*
|
|
1112
|
+
* **不可逆,而且只上报一次**:一次 ENOSPC 往往连着把几个 watcher 全打下来,
|
|
1113
|
+
* 每个都上报的话前端会收到一串重复的降级通知。也**不尝试恢复原生监听** ——
|
|
1114
|
+
* 恢复要么靠定期重试(在真的没有配额时就是每隔一会儿遍历一次整棵树)、要么靠猜,
|
|
1115
|
+
* 而轮询已经是功能完整的通路,唯一代价是 1.5s 延迟。
|
|
1116
|
+
*/
|
|
1117
|
+
const usePolling = (cause) => {
|
|
1118
|
+
if (closed || polling) return;
|
|
1119
|
+
polling = true;
|
|
1120
|
+
if (!pollLoopStarted) startPolling();
|
|
1121
|
+
else if (!pollInFlight) armPoll();
|
|
1122
|
+
if (cause) onDegrade(cause);
|
|
1123
|
+
};
|
|
1124
|
+
const toError = (cause) => cause instanceof Error ? cause : new Error(String(cause));
|
|
1125
|
+
/**
|
|
1126
|
+
* C 档的轮询要在 `.git` 侧之前起,否则那边一个 watch 失败会被当成降级上报:
|
|
1127
|
+
* stderr 打一行、再往刚连上的客户端推一个 `change`,而 `mode` 从头到尾就是
|
|
1128
|
+
* `polling`,没有任何东西真的变过。
|
|
1129
|
+
*
|
|
1130
|
+
* 「哪档以轮询为既定形态」只此一份判据(`initialMode`)—— server 那侧在监听还没
|
|
1131
|
+
* 懒起时也读它来答 `/api/state`,各写各的话两边会在加档位时静默分家。
|
|
1132
|
+
*/
|
|
1133
|
+
if (initialMode(tier) === "polling") usePolling(null);
|
|
1134
|
+
else
|
|
1135
|
+
/**
|
|
1136
|
+
* 原生档的低频安全轮询(§5.7)。**直接起循环而不经 `usePolling`**:那个闩一合上
|
|
1137
|
+
* 就意味着「已降级」,而这里没有任何东西坏掉 —— `mode` 必须还是 `native`,
|
|
1138
|
+
* 否则页面会把一次完全正常的运行标成降级。首拍照例只建立基线。
|
|
1139
|
+
*/
|
|
1140
|
+
startPolling();
|
|
1141
|
+
for (const dir of gitWatchDirs(gitDir)) {
|
|
1142
|
+
/**
|
|
1143
|
+
* **只有 `gitDir` 自己那条算主力**(见 gitWatchDirs 的注释):提交写 `index` 与
|
|
1144
|
+
* `COMMIT_EDITMSG`、切分支写 `HEAD`、fetch 写 `FETCH_HEAD`,全都直接落在它下面。
|
|
1145
|
+
* `refs/` 那两条是锦上添花,它们失败(权限、网络盘)不值得把整个工具**不可逆地**
|
|
1146
|
+
* 拖进轮询 —— 那样 UI 会永远标着「轮询刷新」、进程永远每 1.5s 起一次 git,
|
|
1147
|
+
* 而实际上什么都没坏。
|
|
1148
|
+
*/
|
|
1149
|
+
const primary = dir === gitDir;
|
|
1150
|
+
try {
|
|
1151
|
+
/**
|
|
1152
|
+
* **不传 `recursive`**(见 gitWatchDirs 的注释);`persistent: false` 是说
|
|
1153
|
+
* 监听不吊住事件循环。
|
|
1154
|
+
*
|
|
1155
|
+
* **filename 一概不看**。它可能为 null(§5.7),而这几个目录里的任何写入都
|
|
1156
|
+
* 值得刷新一次:非递归就是真的非递归 —— macOS 已实测(2026-08-11,见 spec §10),
|
|
1157
|
+
* Linux 的 inotify 与 Windows 的 `bWatchSubtree=FALSE` 按机制如此 —— 于是
|
|
1158
|
+
* `objects/` 的海量写入根本到不了这里,没有需要按名字排除的东西。曾按
|
|
1159
|
+
* 「macOS 会漏过来」加过一个顶层段过滤,那是把**建流窗口的补报**(建流前一刻的
|
|
1160
|
+
* 写入会被补进来一两条)误读成了嵌套事件。
|
|
1161
|
+
*/
|
|
1162
|
+
const w = watch(dir, { persistent: false }, () => schedule());
|
|
1163
|
+
w.on("error", (cause) => {
|
|
1164
|
+
/**
|
|
1165
|
+
* 出错的 watcher 已经不再送事件了(Node 在 emit 之前就把 handle 关了),
|
|
1166
|
+
* 留在数组里只会让 `size` 虚高 —— 而 `size` 正是调用方判断「`.git` 侧还
|
|
1167
|
+
* 活着吗」的唯一依据。虚高的那份不会报错,只是判据从此对着一个空壳返回
|
|
1168
|
+
* 「还活着」。
|
|
1169
|
+
*/
|
|
1170
|
+
const at = gitWatchers.indexOf(w);
|
|
1171
|
+
if (at !== -1) gitWatchers.splice(at, 1);
|
|
1172
|
+
if (!closed && primary) usePolling(cause);
|
|
1173
|
+
});
|
|
1174
|
+
gitWatchers.push(w);
|
|
1175
|
+
} catch (cause) {
|
|
1176
|
+
if (primary) usePolling(toError(cause));
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
/**
|
|
1180
|
+
* 工作区侧,按档位分(§5.7 的三档表)。
|
|
1181
|
+
*
|
|
1182
|
+
* **C 档一个递归 watch 都不建**:Node 在 Linux 上的递归实现是用户态遍历,对遍历到的
|
|
1183
|
+
* 每个**普通文件**也注册一个 inotify watch,monorepo 下足以耗尽
|
|
1184
|
+
* `fs.inotify.max_user_watches` —— 之后整机所有依赖 inotify 的工具(包括用户自己的
|
|
1185
|
+
* 编辑器)开始报 ENOSPC。那是本工具唯一可能对用户机器造成的外部副作用。
|
|
1186
|
+
*/
|
|
1187
|
+
if (tier !== "C") try {
|
|
1188
|
+
const handler = (_event, filename) => {
|
|
1189
|
+
/**
|
|
1190
|
+
* **B 档的过滤必须在这里,也就是合并窗口之前**(§5.7 红线)。放在窗口之后
|
|
1191
|
+
* 等于让 `node_modules` 的写入噪声照样把窗口顶开、触发无谓刷新 —— 而
|
|
1192
|
+
* §6 那条「`node_modules` 的嵌套子目录里批量写文件不触发刷新」正是钉这件事。
|
|
1193
|
+
*
|
|
1194
|
+
* A 档不在这里过滤:`ignore` 已经过滤过了(Linux 上还是**注册前跳过**,
|
|
1195
|
+
* 那才是配额问题的解法)。
|
|
1196
|
+
*
|
|
1197
|
+
* `filename` 可能为 null(§5.7,Node 文档载明即便在支持的平台上也不保证提供),
|
|
1198
|
+
* 那时**放行**:漏刷一次比多刷一次糟得多。
|
|
1199
|
+
*/
|
|
1200
|
+
if (tier === "B" && typeof filename === "string" && isIgnored(filename)) return;
|
|
1201
|
+
schedule();
|
|
1202
|
+
};
|
|
1203
|
+
const w = watch(repoRoot, tier === "A" ? {
|
|
1204
|
+
recursive: true,
|
|
1205
|
+
persistent: false,
|
|
1206
|
+
ignore: isIgnored
|
|
1207
|
+
} : {
|
|
1208
|
+
recursive: true,
|
|
1209
|
+
persistent: false
|
|
1210
|
+
}, handler);
|
|
1211
|
+
w.on("error", (cause) => {
|
|
1212
|
+
/**
|
|
1213
|
+
* **必须显式 `close()`,不能只把引用丢掉。**
|
|
1214
|
+
*
|
|
1215
|
+
* 原生 watcher(macOS / Windows,以及非递归那条)确实在 emit 之前就关了自己,
|
|
1216
|
+
* 但 Linux 的**用户态递归实现**不是:核对 `internal/fs/recursive_watch.js`,
|
|
1217
|
+
* `#watchFolder` 的 catch 里只有一句 `this.emit('error', …)`,已经注册的那一
|
|
1218
|
+
* 大批 inotify watch 全都还在,只有显式 `close()` 才放得掉。丢掉引用等于
|
|
1219
|
+
* 让它们活到进程结束 —— 而这条路径最典型的触发原因**正是 inotify 配额耗尽**,
|
|
1220
|
+
* 那时候还占着一堆配额不放,伤的是用户整机的其他工具。
|
|
1221
|
+
*/
|
|
1222
|
+
w.close();
|
|
1223
|
+
workspaceWatcher = null;
|
|
1224
|
+
if (!closed) usePolling(cause);
|
|
1225
|
+
});
|
|
1226
|
+
workspaceWatcher = w;
|
|
1227
|
+
} catch (cause) {
|
|
1228
|
+
usePolling(toError(cause));
|
|
1229
|
+
}
|
|
1230
|
+
return {
|
|
1231
|
+
get size() {
|
|
1232
|
+
return gitWatchers.length;
|
|
1233
|
+
},
|
|
1234
|
+
get mode() {
|
|
1235
|
+
return polling ? "polling" : "native";
|
|
1236
|
+
},
|
|
1237
|
+
close() {
|
|
1238
|
+
closed = true;
|
|
1239
|
+
if (timer !== null) {
|
|
1240
|
+
clearTimeout(timer);
|
|
1241
|
+
timer = null;
|
|
1242
|
+
}
|
|
1243
|
+
if (pollTimer !== null) {
|
|
1244
|
+
clearTimeout(pollTimer);
|
|
1245
|
+
pollTimer = null;
|
|
1246
|
+
}
|
|
1247
|
+
for (const w of gitWatchers) w.close();
|
|
1248
|
+
gitWatchers.length = 0;
|
|
1249
|
+
workspaceWatcher?.close();
|
|
1250
|
+
workspaceWatcher = null;
|
|
1251
|
+
}
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
//#endregion
|
|
1256
|
+
//#region src/server/http/assets.ts
|
|
1257
|
+
/**
|
|
1258
|
+
* 产物目录。
|
|
1259
|
+
*
|
|
1260
|
+
* 口径是**打包后**的位置:`dist/server/main.js` → `dist/web/`。本文件在 src 下的
|
|
1261
|
+
* 相对位置与此无关 —— 它只作为 tsdown 的输入存在,运行时永远是那个单文件 ESM。
|
|
1262
|
+
*/
|
|
1263
|
+
const WEB_ROOT = new URL("../web/", import.meta.url);
|
|
1264
|
+
const ASSETS = /* @__PURE__ */ new Map([
|
|
1265
|
+
["/", {
|
|
1266
|
+
file: "index.html",
|
|
1267
|
+
contentType: "text/html; charset=utf-8"
|
|
1268
|
+
}],
|
|
1269
|
+
["/index.html", {
|
|
1270
|
+
file: "index.html",
|
|
1271
|
+
contentType: "text/html; charset=utf-8"
|
|
1272
|
+
}],
|
|
1273
|
+
["/app.js", {
|
|
1274
|
+
file: "app.js",
|
|
1275
|
+
contentType: "text/javascript; charset=utf-8"
|
|
1276
|
+
}],
|
|
1277
|
+
["/app.css", {
|
|
1278
|
+
file: "app.css",
|
|
1279
|
+
contentType: "text/css; charset=utf-8"
|
|
1280
|
+
}]
|
|
1281
|
+
]);
|
|
1282
|
+
/**
|
|
1283
|
+
* 读过一次就留在内存里。
|
|
1284
|
+
*
|
|
1285
|
+
* `dist/web/` 在进程生命周期内不会变(产物由构建产生,服务端不监听它),而所有响应
|
|
1286
|
+
* 都带 `Cache-Control: no-store`,浏览器那侧也不缓存 —— 不留的话每次刷新都要把三个
|
|
1287
|
+
* 文件重新读一遍磁盘。三份加起来两百多 KB,常驻的代价可以忽略。
|
|
1288
|
+
*/
|
|
1289
|
+
const cache = /* @__PURE__ */ new Map();
|
|
1290
|
+
async function readAsset(asset) {
|
|
1291
|
+
const cached = cache.get(asset.file);
|
|
1292
|
+
if (cached) return cached;
|
|
1293
|
+
const buffer = await readFile(fileURLToPath(new URL(asset.file, WEB_ROOT)));
|
|
1294
|
+
cache.set(asset.file, buffer);
|
|
1295
|
+
return buffer;
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
//#endregion
|
|
1299
|
+
//#region src/server/http/idle.ts
|
|
1300
|
+
/** 宽限期。取 30-60s 区间中值(spec §5.8)。 */
|
|
1301
|
+
const IDLE_EXIT_MS = 45e3;
|
|
1302
|
+
/** 内部环境变量,名字定在 spec §5.8。不是给用户的开关,不进 `--help` 与 README。 */
|
|
1303
|
+
const IDLE_ENV = "DIFFTAB_IDLE_MS";
|
|
1304
|
+
var IdleConfigError = class extends Error {
|
|
1305
|
+
constructor(message) {
|
|
1306
|
+
super(message);
|
|
1307
|
+
this.name = "IdleConfigError";
|
|
1308
|
+
}
|
|
1309
|
+
};
|
|
1310
|
+
/**
|
|
1311
|
+
* 读宽限期。
|
|
1312
|
+
*
|
|
1313
|
+
* **取值不合法即启动失败,不退回默认的 45s** —— 与 §5.7 的 `DIFFTAB_WATCH_TIER`
|
|
1314
|
+
* 同一条理由:退回时手滑写错的那次照样启动成功、照样跑出一个看着合理的行为,
|
|
1315
|
+
* 于是「我验过空闲退出了」建立在一次根本没生效的强制指定上(那条用例会等满 45s
|
|
1316
|
+
* 而不是它以为的 800ms,表现为超时,读起来像产品坏了)。
|
|
1317
|
+
*/
|
|
1318
|
+
function resolveIdleMs(env = process.env) {
|
|
1319
|
+
const raw = env[IDLE_ENV];
|
|
1320
|
+
if (raw === void 0 || raw === "") return IDLE_EXIT_MS;
|
|
1321
|
+
const parsed = Number(raw);
|
|
1322
|
+
if (!Number.isFinite(parsed) || parsed <= 0) throw new IdleConfigError(`${IDLE_ENV} must be a positive number of milliseconds, got "${raw}"`);
|
|
1323
|
+
return parsed;
|
|
1324
|
+
}
|
|
1325
|
+
function createIdleWatchdog(options) {
|
|
1326
|
+
const { idleMs, hasClients, onIdle } = options;
|
|
1327
|
+
let timer = null;
|
|
1328
|
+
let stopped = false;
|
|
1329
|
+
const disarm = () => {
|
|
1330
|
+
if (timer === null) return;
|
|
1331
|
+
clearTimeout(timer);
|
|
1332
|
+
timer = null;
|
|
1333
|
+
};
|
|
1334
|
+
return {
|
|
1335
|
+
touch() {
|
|
1336
|
+
disarm();
|
|
1337
|
+
if (stopped || hasClients()) return;
|
|
1338
|
+
timer = setTimeout(() => {
|
|
1339
|
+
timer = null;
|
|
1340
|
+
/**
|
|
1341
|
+
* **落地前再问一次**。定时器排队与回调执行之间隔着整个事件循环的这一圈,
|
|
1342
|
+
* 同一圈里完全可能刚接进一个 SSE 连接;而这个判断错了的症状是「用户正开着
|
|
1343
|
+
* 页面,进程自己没了」—— 比多活 45 秒糟得多。
|
|
1344
|
+
*/
|
|
1345
|
+
if (stopped || hasClients()) return;
|
|
1346
|
+
onIdle();
|
|
1347
|
+
}, idleMs);
|
|
1348
|
+
/**
|
|
1349
|
+
* **计时器不是进程活着的理由**,HTTP server 的监听句柄才是(与 sse.ts 的心跳、
|
|
1350
|
+
* watcher 的合并窗口同一条)。不 unref 的话,close() 之后漏了 stop() 就会把
|
|
1351
|
+
* 进程多吊住一个宽限期,而那种残留最难看见。
|
|
1352
|
+
*/
|
|
1353
|
+
timer.unref();
|
|
1354
|
+
},
|
|
1355
|
+
stop() {
|
|
1356
|
+
stopped = true;
|
|
1357
|
+
disarm();
|
|
1358
|
+
}
|
|
1359
|
+
};
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
//#endregion
|
|
1363
|
+
//#region src/server/http/security.ts
|
|
1364
|
+
/** 服务只绑这个地址。Host 白名单里的两个名字都指向它。 */
|
|
1365
|
+
const BIND_HOST = "127.0.0.1";
|
|
1366
|
+
const ALLOWED_HOSTNAMES = ["127.0.0.1", "localhost"];
|
|
1367
|
+
/**
|
|
1368
|
+
* cookie 名带上端口。
|
|
1369
|
+
*
|
|
1370
|
+
* cookie 的作用域是 host 而非 origin,**不隔离端口**(spec §5.9):同机另一个监听
|
|
1371
|
+
* `127.0.0.1:<其他端口>` 的服务同样会收到我们的 cookie,反过来它设的同名 cookie
|
|
1372
|
+
* 也会盖掉我们的。名字里带端口,两个 difftab 实例(不同仓库、不同端口)才能
|
|
1373
|
+
* 在同一个浏览器里共存。
|
|
1374
|
+
*/
|
|
1375
|
+
function cookieName(port) {
|
|
1376
|
+
return `difftab_token_${port}`;
|
|
1377
|
+
}
|
|
1378
|
+
/** 会话 token 的秘密部分。进程生命周期内持续有效,以支持页面刷新与多标签。 */
|
|
1379
|
+
function createSecret() {
|
|
1380
|
+
return randomBytes(32).toString("base64url");
|
|
1381
|
+
}
|
|
1382
|
+
/**
|
|
1383
|
+
* token 形如 `<port>.<secret>`。
|
|
1384
|
+
*
|
|
1385
|
+
* 端口写进 token 本身,是为了让服务端校验时**一并绑定校验本次会话的端口**
|
|
1386
|
+
* (spec §5.9 第 3 条):cookie 会泄漏给本机其他 localhost 服务,带上端口后
|
|
1387
|
+
* 那份 token 在别处无法复用。
|
|
1388
|
+
*/
|
|
1389
|
+
function composeToken(port, secret) {
|
|
1390
|
+
return `${port}.${secret}`;
|
|
1391
|
+
}
|
|
1392
|
+
/**
|
|
1393
|
+
* 打印给用户、也拿去拉起浏览器的那个 URL。
|
|
1394
|
+
*
|
|
1395
|
+
* **只此一处**:探活复用命中时,是**另一个进程**按注册表里的 port + token 重新拼出
|
|
1396
|
+
* 同一个 URL 交给浏览器(§5.8)。两处各拼一遍的话,哪天 token 的落地方式变了
|
|
1397
|
+
* (换 query 名、换编码),复用那条路会拼出一个 403 的链接 —— 而它平时不走,
|
|
1398
|
+
* 谁也不会先注意到。
|
|
1399
|
+
*/
|
|
1400
|
+
function sessionUrl(port, token) {
|
|
1401
|
+
return `http://${BIND_HOST}:${port}/?token=${encodeURIComponent(token)}`;
|
|
1402
|
+
}
|
|
1403
|
+
/**
|
|
1404
|
+
* 第 1 道:**校验 `Host` 头**。这才是 DNS rebinding 的正面防御 —— token 挡不住
|
|
1405
|
+
* 同源判定本身,rebinding 的攻击页面把自己的域名重绑到 127.0.0.1 后,浏览器
|
|
1406
|
+
* 认为它与本服务同源,而它发出的请求 `Host` 是攻击者的域名。
|
|
1407
|
+
*/
|
|
1408
|
+
function isHostAllowed(host, port) {
|
|
1409
|
+
if (!host) return false;
|
|
1410
|
+
return ALLOWED_HOSTNAMES.some((name) => host === `${name}:${port}`);
|
|
1411
|
+
}
|
|
1412
|
+
/**
|
|
1413
|
+
* 第 2 道:**校验 `Origin`**。非空且不等于自身则拒。
|
|
1414
|
+
*
|
|
1415
|
+
* 空是正常的:同源的导航与 GET fetch 本来就不带 Origin。`null`(沙箱化 iframe、
|
|
1416
|
+
* 跨源重定向)是非空值,按不匹配处理 —— 那正是要挡的东西。
|
|
1417
|
+
*/
|
|
1418
|
+
function isOriginAllowed(origin, port) {
|
|
1419
|
+
if (!origin) return true;
|
|
1420
|
+
return ALLOWED_HOSTNAMES.some((name) => origin === `http://${name}:${port}`);
|
|
1421
|
+
}
|
|
1422
|
+
/** 从 `Cookie` 头里取出本会话的 token。 */
|
|
1423
|
+
function readCookieToken(header, port) {
|
|
1424
|
+
if (!header) return null;
|
|
1425
|
+
const wanted = cookieName(port);
|
|
1426
|
+
for (const part of header.split(";")) {
|
|
1427
|
+
const eq = part.indexOf("=");
|
|
1428
|
+
if (eq === -1) continue;
|
|
1429
|
+
if (part.slice(0, eq).trim() === wanted) return part.slice(eq + 1).trim();
|
|
1430
|
+
}
|
|
1431
|
+
return null;
|
|
1432
|
+
}
|
|
1433
|
+
/** 定长比较。长度不同直接判否 —— 长度本身不是秘密。 */
|
|
1434
|
+
function tokensMatch(presented, expected) {
|
|
1435
|
+
if (!presented) return false;
|
|
1436
|
+
const a = Buffer.from(presented, "utf8");
|
|
1437
|
+
const b = Buffer.from(expected, "utf8");
|
|
1438
|
+
if (a.length !== b.length) return false;
|
|
1439
|
+
return timingSafeEqual(a, b);
|
|
1440
|
+
}
|
|
1441
|
+
/**
|
|
1442
|
+
* 第 3 道之外的公共响应头。
|
|
1443
|
+
*
|
|
1444
|
+
* CSP 的后三个指令**不回退到 `default-src`**,不显式写就等于没设(spec §5.9 / §10):
|
|
1445
|
+
* `'none'` 一并挡掉被 iframe 嵌套、`<base>` 改写相对 URL 与表单外发。
|
|
1446
|
+
* 不开 `'unsafe-inline'` 是 §5.11 构建链路顺带解锁的 —— 产物是独立的 .js / .css、
|
|
1447
|
+
* 页面无内联脚本,才有条件不开。
|
|
1448
|
+
*/
|
|
1449
|
+
const SECURITY_HEADERS = {
|
|
1450
|
+
"Cache-Control": "no-store",
|
|
1451
|
+
"X-Content-Type-Options": "nosniff",
|
|
1452
|
+
"Content-Security-Policy": [
|
|
1453
|
+
"default-src 'none'",
|
|
1454
|
+
"script-src 'self'",
|
|
1455
|
+
"style-src 'self'",
|
|
1456
|
+
"connect-src 'self'",
|
|
1457
|
+
"img-src 'self' data:",
|
|
1458
|
+
"frame-ancestors 'none'",
|
|
1459
|
+
"base-uri 'none'",
|
|
1460
|
+
"form-action 'none'"
|
|
1461
|
+
].join("; ")
|
|
1462
|
+
};
|
|
1463
|
+
/**
|
|
1464
|
+
* 把 token 落成 cookie 的 Set-Cookie 值。
|
|
1465
|
+
*
|
|
1466
|
+
* `HttpOnly; SameSite=Strict` + 随后 302 掉 query,避免 token 长期滞留在浏览器
|
|
1467
|
+
* 历史、地址栏和日志中(spec §5.9)。不设 `Secure` —— 那会让 http://127.0.0.1
|
|
1468
|
+
* 上的 cookie 直接不生效。
|
|
1469
|
+
*/
|
|
1470
|
+
function tokenCookie(port, token) {
|
|
1471
|
+
return `${cookieName(port)}=${token}; Path=/; HttpOnly; SameSite=Strict`;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
//#endregion
|
|
1475
|
+
//#region src/server/shared/protocol.ts
|
|
1476
|
+
/**
|
|
1477
|
+
* 服务端 SSE 心跳周期(spec §5.8「服务端 SSE 心跳约 15s」)。
|
|
1478
|
+
*
|
|
1479
|
+
* **它是协议的一部分,所以定在这里而不是 http/sse.ts**:后端拿它当发送周期,前端
|
|
1480
|
+
* 拿它当「对端还在」的判据(`web/state/events.ts` 的 `STALE_MS` 由它推出来)。两边
|
|
1481
|
+
* 各写一个 15000,改周期时只改一边不会报错、也没有任何用例会红 —— 前端的死连接
|
|
1482
|
+
* 判定就此静默失准,而症状只是「有时候刷不出来」。
|
|
1483
|
+
*/
|
|
1484
|
+
const HEARTBEAT_MS = 15e3;
|
|
1485
|
+
|
|
1486
|
+
//#endregion
|
|
1487
|
+
//#region src/server/http/sse.ts
|
|
1488
|
+
/**
|
|
1489
|
+
* 一条 SSE 消息的线格式。
|
|
1490
|
+
*
|
|
1491
|
+
* `data` 一律是一行 JSON:多行正文要逐行加 `data: ` 前缀,而正文里一旦出现换行
|
|
1492
|
+
* (将来带上文件名之类)就会静默变成半条消息。`JSON.stringify` 保证不含裸换行。
|
|
1493
|
+
*/
|
|
1494
|
+
function formatEvent(name, data) {
|
|
1495
|
+
return `event: ${name}\ndata: ${JSON.stringify(data)}\n\n`;
|
|
1496
|
+
}
|
|
1497
|
+
function createSseChannel({ heartbeatMs = HEARTBEAT_MS, onChange } = {}) {
|
|
1498
|
+
const clients = /* @__PURE__ */ new Set();
|
|
1499
|
+
const send = (name, data) => {
|
|
1500
|
+
const chunk = formatEvent(name, data);
|
|
1501
|
+
for (const client of clients) try {
|
|
1502
|
+
client.write(chunk);
|
|
1503
|
+
} catch {
|
|
1504
|
+
/**
|
|
1505
|
+
* **真正把死连接摘出去的不是这里**,是端点上的 `'close'` / `'error'` 监听器:
|
|
1506
|
+
* `ServerResponse.write()` 对已结束或已销毁的响应不会同步抛,失败是异步的。
|
|
1507
|
+
* 这层 catch 只保证一个客户端写炸了不会让这一轮广播半路夭折 —— 后面的连接
|
|
1508
|
+
* 照样收得到。
|
|
1509
|
+
*/
|
|
1510
|
+
clients.delete(client);
|
|
1511
|
+
}
|
|
1512
|
+
};
|
|
1513
|
+
const heartbeat = setInterval(() => send("heartbeat", {}), heartbeatMs);
|
|
1514
|
+
heartbeat.unref();
|
|
1515
|
+
return {
|
|
1516
|
+
add(client) {
|
|
1517
|
+
clients.add(client);
|
|
1518
|
+
onChange?.();
|
|
1519
|
+
},
|
|
1520
|
+
remove(client) {
|
|
1521
|
+
clients.delete(client);
|
|
1522
|
+
onChange?.();
|
|
1523
|
+
},
|
|
1524
|
+
get size() {
|
|
1525
|
+
return clients.size;
|
|
1526
|
+
},
|
|
1527
|
+
send,
|
|
1528
|
+
close() {
|
|
1529
|
+
clearInterval(heartbeat);
|
|
1530
|
+
for (const client of clients) try {
|
|
1531
|
+
client.end();
|
|
1532
|
+
} catch {}
|
|
1533
|
+
clients.clear();
|
|
1534
|
+
}
|
|
1535
|
+
};
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1538
|
+
//#endregion
|
|
1539
|
+
//#region src/server/http/server.ts
|
|
1540
|
+
/**
|
|
1541
|
+
* 错误信息不含绝对路径(spec §5.12 / §5.9)。
|
|
1542
|
+
*
|
|
1543
|
+
* git 的 fatal 文本里经常带着完整仓库路径,原样回给页面就把本机目录结构泄漏进了
|
|
1544
|
+
* 一个任何本地页面都可能读到的响应体。
|
|
1545
|
+
*/
|
|
1546
|
+
function sanitizeMessage(message, repoRoot) {
|
|
1547
|
+
const home = homedir();
|
|
1548
|
+
let out = message.split(repoRoot).join("<repo>");
|
|
1549
|
+
if (home) out = out.split(home).join("~");
|
|
1550
|
+
return out.split("\n")[0]?.trim() ?? "";
|
|
1551
|
+
}
|
|
1552
|
+
function send(res, status, body, contentType) {
|
|
1553
|
+
const payload = typeof body === "string" ? Buffer.from(body, "utf8") : body;
|
|
1554
|
+
res.writeHead(status, {
|
|
1555
|
+
...SECURITY_HEADERS,
|
|
1556
|
+
"Content-Type": contentType,
|
|
1557
|
+
"Content-Length": payload.byteLength
|
|
1558
|
+
});
|
|
1559
|
+
res.end(payload);
|
|
1560
|
+
}
|
|
1561
|
+
function sendJson(res, status, body) {
|
|
1562
|
+
send(res, status, JSON.stringify(body), "application/json; charset=utf-8");
|
|
1563
|
+
}
|
|
1564
|
+
async function startServer(repo, options = {}) {
|
|
1565
|
+
/**
|
|
1566
|
+
* 出站错误一律经此发出,**sanitize 就做在这里**。
|
|
1567
|
+
*
|
|
1568
|
+
* 早先只在兜底的 500 分支上过一次 sanitizeMessage,于是「响应体不含绝对路径」这条
|
|
1569
|
+
* 不变式实际挂在「以后新增的错误消息恰好都不带路径」上 —— 那不是不变式,是巧合。
|
|
1570
|
+
* 放在唯一出口上,新增分支自动被覆盖(spec §5.12)。
|
|
1571
|
+
*/
|
|
1572
|
+
const sendError = (res, status, code, message) => {
|
|
1573
|
+
sendJson(res, status, { error: {
|
|
1574
|
+
code,
|
|
1575
|
+
message: sanitizeMessage(message, repo.root)
|
|
1576
|
+
} });
|
|
1577
|
+
};
|
|
1578
|
+
const secret = createSecret();
|
|
1579
|
+
let token = "";
|
|
1580
|
+
let port = 0;
|
|
1581
|
+
/**
|
|
1582
|
+
* 档位在**启动时**定,不等第一个订阅者。
|
|
1583
|
+
*
|
|
1584
|
+
* `/api/state` 里就带着它(§5.12),而那个请求先于任何 SSE 连接到达;更要紧的是
|
|
1585
|
+
* `DIFFTAB_WATCH_TIER` 写错时要在启动那一刻响亮地失败,而不是等到某次订阅。
|
|
1586
|
+
* 判定本身是纯计算(读 `process.versions.node` 与 `process.platform`),不落在
|
|
1587
|
+
* §6 的 300ms 冷启动预算上。
|
|
1588
|
+
*/
|
|
1589
|
+
const tier = resolveTier();
|
|
1590
|
+
const tierWarning = forcedTierWarning();
|
|
1591
|
+
if (tierWarning) process.stderr.write(`difftab: ${tierWarning}\n`);
|
|
1592
|
+
/**
|
|
1593
|
+
* 通道与空闲计时器互相接线:连接数一变(add / remove)就 `touch` 一次。
|
|
1594
|
+
* 端点那侧因此**不必记得**在断连时重新武装 —— 见 sse.ts 的 `onChange`。
|
|
1595
|
+
*/
|
|
1596
|
+
const events = createSseChannel({ onChange: () => idle.touch() });
|
|
1597
|
+
/**
|
|
1598
|
+
* 空闲退出的计时器(spec §5.8)。**判据是 SSE 连接数,但任何通过校验的请求都重置
|
|
1599
|
+
* 计时** —— 连接数是正面判据,而它在两种情形下同样是 0:刚被探活复用、浏览器
|
|
1600
|
+
* 还在启动的那几秒,以及页面活着但 SSE 被中间层悄悄回收了。两者取并集,退出条件
|
|
1601
|
+
* 因此严格弱于「连接数为 0 持续 45s」,只会晚退不会早退。
|
|
1602
|
+
*
|
|
1603
|
+
* `resolveIdleMs()` 在这里(listen 之前)读环境变量:写错的取值要在启动那一刻
|
|
1604
|
+
* 响亮地失败,而不是等到 45 秒后什么都没发生。
|
|
1605
|
+
*/
|
|
1606
|
+
const idle = createIdleWatchdog({
|
|
1607
|
+
idleMs: resolveIdleMs(),
|
|
1608
|
+
hasClients: () => events.size > 0,
|
|
1609
|
+
onIdle: () => options.onIdle?.()
|
|
1610
|
+
});
|
|
1611
|
+
/**
|
|
1612
|
+
* 监听**懒起**:第一个 SSE 订阅者到了才建,起了就一直留到关服务。
|
|
1613
|
+
*
|
|
1614
|
+
* 两头都是有理由的。懒起是因为 §6 的冷启动门禁量的是「监听成功并打印 URL」,而
|
|
1615
|
+
* S3b2 的 A/B 档要在 repoRoot 上建递归 watch —— Linux 上那是用户态遍历整棵树,
|
|
1616
|
+
* 大仓库里足以把 300ms 预算一口吃掉,而此刻还没有任何人在等变更通知。
|
|
1617
|
+
* 不随最后一个订阅者关掉,则是因为刷新页面 = 断开再连,那会让上面那趟遍历
|
|
1618
|
+
* 每刷新一次重来一遍;空闲着的原生 watch 本身开销接近零(§6 的资源占用项)。
|
|
1619
|
+
*/
|
|
1620
|
+
let watcher = null;
|
|
1621
|
+
const ensureWatcher = () => {
|
|
1622
|
+
if (watcher !== null) return;
|
|
1623
|
+
watcher = createWatcher({
|
|
1624
|
+
gitDir: repo.gitDir,
|
|
1625
|
+
repoRoot: repo.root,
|
|
1626
|
+
tier,
|
|
1627
|
+
onChange: () => events.send("change", {}),
|
|
1628
|
+
/**
|
|
1629
|
+
* 轮询探针。**注入而不是让 watch/ 自己去调 git**:git 子进程只许出现在
|
|
1630
|
+
* server/git(§5.0 不变式 1),而 §5.0 的依赖方向里也没有 watch → git 这条边。
|
|
1631
|
+
* 传进去的是主查询本身(`readStatusRaw` 用的就是 `STATUS_ARGS`),于是「轮询
|
|
1632
|
+
* 与主查询逐字相同」这条红线在这里是**一处赋值**而不是两处各自维护的巧合。
|
|
1633
|
+
* 拦住它的不是类型(签名只是 `() => Promise<string>`),是这个唯一的注入点
|
|
1634
|
+
* 加上冒烟里那条「C 档在已存在的未跟踪目录里新增文件要推出 change」。
|
|
1635
|
+
*
|
|
1636
|
+
* **已知的覆盖边界**:`/api/state` 里的 `operation`(§5.3)不来自 status 输出,
|
|
1637
|
+
* 所以这条探针看不见它。三档都在 `gitDir` 上建了非递归 watch,`MERGE_HEAD` /
|
|
1638
|
+
* `rebase-merge/` 的增删就落在那儿,正常情况下照样推得出 change;只有**那条
|
|
1639
|
+
* watch 自己也失败、整体落到轮询**之后,一次「只动 git 目录、不动 HEAD 也不动
|
|
1640
|
+
* 工作区」的操作(实际上只有 `rebase --quit` 这一类)才会让标注停在旧值上。
|
|
1641
|
+
* 不为它把探针拆成两个来源:那会让「轮询与主查询是同一条命令」不再是构造上的
|
|
1642
|
+
* 事实,而换来的是一个交集极窄的窗口。
|
|
1643
|
+
*/
|
|
1644
|
+
pollStatus: () => readStatusRaw(repo.root),
|
|
1645
|
+
/**
|
|
1646
|
+
* 降级为轮询(§5.7 的兜底)。**推一个 `change` 是必需的**:`mode` 只在
|
|
1647
|
+
* `/api/state` 里,前端不重取就看不到降级 —— 而它自己无从推断这件事(§5.12),
|
|
1648
|
+
* 于是页面会一直标着「原生监听」直到下一次真的有文件变更。
|
|
1649
|
+
*/
|
|
1650
|
+
onDegrade: (cause) => {
|
|
1651
|
+
process.stderr.write(`difftab: file watching degraded — ${sanitizeMessage(cause.message, repo.root)}\n`);
|
|
1652
|
+
events.send("change", {});
|
|
1653
|
+
}
|
|
1654
|
+
});
|
|
1655
|
+
};
|
|
1656
|
+
/**
|
|
1657
|
+
* 当前的监听状态(§5.12 的 `WatchState`)。
|
|
1658
|
+
*
|
|
1659
|
+
* **每次请求现算,不是启动时算一次**:降级发生在运行中,而算一次存起来的那份
|
|
1660
|
+
* 不会报错,只是从此永远说「原生监听」。监听懒起(见上),还没起来时按档位的
|
|
1661
|
+
* 既定形态答 —— C 档从第一份 `/api/state` 起就该是 `polling`。
|
|
1662
|
+
*/
|
|
1663
|
+
const currentWatchState = () => ({
|
|
1664
|
+
mode: watcher?.mode ?? initialMode(tier),
|
|
1665
|
+
tier
|
|
1666
|
+
});
|
|
1667
|
+
const handle = async (req, res) => {
|
|
1668
|
+
if (!isHostAllowed(req.headers.host, port)) {
|
|
1669
|
+
sendError(res, 403, "forbidden", "forbidden");
|
|
1670
|
+
return;
|
|
1671
|
+
}
|
|
1672
|
+
if (!isOriginAllowed(req.headers.origin, port)) {
|
|
1673
|
+
sendError(res, 403, "forbidden", "forbidden");
|
|
1674
|
+
return;
|
|
1675
|
+
}
|
|
1676
|
+
const url = new URL(req.url ?? "/", `http://${BIND_HOST}:${port}`);
|
|
1677
|
+
const fromQuery = url.searchParams.get("token");
|
|
1678
|
+
if (fromQuery !== null) {
|
|
1679
|
+
if (!tokensMatch(fromQuery, token)) {
|
|
1680
|
+
sendError(res, 403, "forbidden", "forbidden");
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1683
|
+
idle.touch();
|
|
1684
|
+
url.searchParams.delete("token");
|
|
1685
|
+
res.writeHead(302, {
|
|
1686
|
+
...SECURITY_HEADERS,
|
|
1687
|
+
"Set-Cookie": tokenCookie(port, token),
|
|
1688
|
+
Location: `${url.pathname}${url.search}`,
|
|
1689
|
+
"Content-Length": 0
|
|
1690
|
+
});
|
|
1691
|
+
res.end();
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
if (!tokensMatch(readCookieToken(req.headers.cookie, port), token)) {
|
|
1695
|
+
sendError(res, 403, "forbidden", "forbidden");
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
/**
|
|
1699
|
+
* 到这里才算「有人在」,**三道校验之前不算**(上面那个 302 分支同理:token 对了
|
|
1700
|
+
* 才 touch)。放在函数开头看起来更省事,代价是本机任何一个端口扫描器都能无限期
|
|
1701
|
+
* 续命一个该退的进程 —— 而 §5.8 的承诺是「不留后台常驻进程」。
|
|
1702
|
+
*/
|
|
1703
|
+
idle.touch();
|
|
1704
|
+
/**
|
|
1705
|
+
* 只读工具不需要任何非幂等端点(spec §5.12)。
|
|
1706
|
+
*
|
|
1707
|
+
* **必须留在三道校验之后,不得挪回函数开头** —— 理由(泄漏服务存在性)见 spec §5.9
|
|
1708
|
+
* 第 4 条,验收项 §6 `[S5]`。副作用是上面那个 query token 分支也排在它前面:带合法
|
|
1709
|
+
* token 的非幂等请求会先被 302 换成 cookie 而不是 405。它已经过了三道校验,是取舍。
|
|
1710
|
+
*/
|
|
1711
|
+
if (req.method !== "GET" && req.method !== "HEAD") {
|
|
1712
|
+
sendError(res, 405, "method-not-allowed", "only GET is supported");
|
|
1713
|
+
return;
|
|
1714
|
+
}
|
|
1715
|
+
const asset = ASSETS.get(url.pathname);
|
|
1716
|
+
if (asset) {
|
|
1717
|
+
send(res, 200, await readAsset(asset), asset.contentType);
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
switch (url.pathname) {
|
|
1721
|
+
case "/api/state": {
|
|
1722
|
+
const status = await readStatus(repo);
|
|
1723
|
+
sendJson(res, 200, {
|
|
1724
|
+
branch: status.branch,
|
|
1725
|
+
files: status.files,
|
|
1726
|
+
watch: currentWatchState()
|
|
1727
|
+
});
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
/**
|
|
1731
|
+
* 探活复用的应答(spec §5.8 / §5.12)。**消费者是下一个 CLI 进程,不是前端。**
|
|
1732
|
+
*
|
|
1733
|
+
* 它答的是「这个端口现在还是**这个仓库**的实例吗」。光有三道校验答不了:
|
|
1734
|
+
* token 不匹配只证明「不是我们这一份会话」,而端口被系统回收给另一个仓库的
|
|
1735
|
+
* difftab 时,那边同样有一份合法 token —— 只不过不是我们记在注册表里的那个,
|
|
1736
|
+
* 于是它会 403、被判为陈旧,正确。真正需要这个正文的是相反的一侧:200 之后
|
|
1737
|
+
* 还要确认路径确实是我们这个仓库,否则「复用」会把用户带到别人的页面(§5.8
|
|
1738
|
+
* 排除 pid 判活也是同一条理由)。
|
|
1739
|
+
*/
|
|
1740
|
+
case "/api/instance":
|
|
1741
|
+
sendJson(res, 200, {
|
|
1742
|
+
repoRoot: repo.root,
|
|
1743
|
+
pid: process.pid
|
|
1744
|
+
});
|
|
1745
|
+
return;
|
|
1746
|
+
case "/api/diff": {
|
|
1747
|
+
const path = url.searchParams.get("path");
|
|
1748
|
+
if (!path) {
|
|
1749
|
+
sendError(res, 400, "bad-request", "path is required");
|
|
1750
|
+
return;
|
|
1751
|
+
}
|
|
1752
|
+
const oldPath = url.searchParams.get("oldPath");
|
|
1753
|
+
sendJson(res, 200, await readDiff(repo.root, {
|
|
1754
|
+
path,
|
|
1755
|
+
...oldPath ? { oldPath } : {}
|
|
1756
|
+
}));
|
|
1757
|
+
return;
|
|
1758
|
+
}
|
|
1759
|
+
/**
|
|
1760
|
+
* SSE(spec §5.8 / §5.12)。**三道校验在上面已经统一走过**,这里没有例外分支 ——
|
|
1761
|
+
* 「所有端点(含 SSE)统一校验」正是 §5.9 第 4 条。
|
|
1762
|
+
*/
|
|
1763
|
+
case "/api/events": {
|
|
1764
|
+
const headers = {
|
|
1765
|
+
...SECURITY_HEADERS,
|
|
1766
|
+
"Content-Type": "text/event-stream; charset=utf-8",
|
|
1767
|
+
Connection: "keep-alive"
|
|
1768
|
+
};
|
|
1769
|
+
if (req.method === "HEAD") {
|
|
1770
|
+
res.writeHead(200, headers);
|
|
1771
|
+
res.end();
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1774
|
+
res.writeHead(200, headers);
|
|
1775
|
+
res.write(": connected\n\n");
|
|
1776
|
+
/**
|
|
1777
|
+
* 两个监听器都必须挂在 `events.add` **之前**。
|
|
1778
|
+
*
|
|
1779
|
+
* `close` 覆盖全部断开路径(关标签、刷新、进程被 kill 掉对端);`error` 则是
|
|
1780
|
+
* 长连接独有的一条:写向一条已经结束的响应,`write()` 不会同步抛,而是在 `res`
|
|
1781
|
+
* 上**异步** emit 一个 `'error'` —— 一个零监听器的 EventEmitter 收到 `'error'`
|
|
1782
|
+
* 是整个进程带着裸栈崩掉,而不是丢一条心跳。
|
|
1783
|
+
*
|
|
1784
|
+
* 顺序则是因为 add 之后、挂上之前的那一小段里断开的话,就再没人把它摘出去:
|
|
1785
|
+
* 心跳会一直写向一条死响应,§5.8 的空闲退出也永远数不回 0。
|
|
1786
|
+
*/
|
|
1787
|
+
/**
|
|
1788
|
+
* 断开之后**必须再 touch 一次**:计时只在 `touch()` 里起,而这是「最后一个
|
|
1789
|
+
* 标签被关掉」在服务端的唯一形态。少了它,进程要等到下一个请求(而那正是
|
|
1790
|
+
* 没有人再发的东西)才想起来自己已经空了 —— 症状是关完浏览器留一个永久
|
|
1791
|
+
* 常驻进程,与 §6 那条验收项正相反。
|
|
1792
|
+
*/
|
|
1793
|
+
res.on("close", () => events.remove(res));
|
|
1794
|
+
res.on("error", () => events.remove(res));
|
|
1795
|
+
events.add(res);
|
|
1796
|
+
/**
|
|
1797
|
+
* **监听推到下一拍再起**,不在这个请求里同步建。
|
|
1798
|
+
*
|
|
1799
|
+
* Linux 上 A / B 档的递归 watch 是**用户态的同步遍历**(`readdirSync` +
|
|
1800
|
+
* `statSync` 递归,已核对 `internal/fs/recursive_watch.js`),大仓库上要跑
|
|
1801
|
+
* 几百毫秒到数秒。留在这里的话,它占住的是整条事件循环 —— 页面此刻正并发
|
|
1802
|
+
* 发着 `/api/state` 与静态资源,那些请求会一起卡住,首屏因此变慢,
|
|
1803
|
+
* 而症状与「监听很慢」毫无相似之处。
|
|
1804
|
+
*/
|
|
1805
|
+
setImmediate(ensureWatcher);
|
|
1806
|
+
return;
|
|
1807
|
+
}
|
|
1808
|
+
default: sendError(res, 404, "not-found", "not found");
|
|
1809
|
+
}
|
|
1810
|
+
};
|
|
1811
|
+
const server = createServer((req, res) => {
|
|
1812
|
+
handle(req, res).catch((cause) => {
|
|
1813
|
+
if (res.headersSent) {
|
|
1814
|
+
res.destroy();
|
|
1815
|
+
return;
|
|
1816
|
+
}
|
|
1817
|
+
if (cause instanceof DiffRequestError) {
|
|
1818
|
+
sendError(res, 400, cause.code, cause.message);
|
|
1819
|
+
return;
|
|
1820
|
+
}
|
|
1821
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
1822
|
+
sendError(res, 500, "internal", detail);
|
|
1823
|
+
});
|
|
1824
|
+
});
|
|
1825
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
1826
|
+
server.on("connection", (socket) => {
|
|
1827
|
+
sockets.add(socket);
|
|
1828
|
+
socket.on("close", () => sockets.delete(socket));
|
|
1829
|
+
});
|
|
1830
|
+
await new Promise((resolvePromise, rejectPromise) => {
|
|
1831
|
+
server.once("error", rejectPromise);
|
|
1832
|
+
server.listen(0, BIND_HOST, () => {
|
|
1833
|
+
server.removeListener("error", rejectPromise);
|
|
1834
|
+
resolvePromise();
|
|
1835
|
+
});
|
|
1836
|
+
});
|
|
1837
|
+
server.on("error", (cause) => {
|
|
1838
|
+
process.stderr.write(`difftab: local server error — ${sanitizeMessage(cause.message, repo.root)}\n`);
|
|
1839
|
+
});
|
|
1840
|
+
const address = server.address();
|
|
1841
|
+
if (typeof address === "string" || address === null) throw new Error("failed to determine the listening port");
|
|
1842
|
+
port = address.port;
|
|
1843
|
+
token = composeToken(port, secret);
|
|
1844
|
+
/**
|
|
1845
|
+
* **宽限期从这一刻就开始计,不等第一个客户端**(spec §5.8)。
|
|
1846
|
+
*
|
|
1847
|
+
* 等第一个客户端才起计时看着更稳妥,实则把「浏览器压根没拉起来」整类情形
|
|
1848
|
+
* (headless、无 `xdg-open`、`--no-open` 之后用户改了主意)变成永久常驻的后台进程。
|
|
1849
|
+
* 45 秒足够覆盖冷启动一个浏览器进程的 2-5s(§6)。
|
|
1850
|
+
*/
|
|
1851
|
+
idle.touch();
|
|
1852
|
+
return {
|
|
1853
|
+
port,
|
|
1854
|
+
token,
|
|
1855
|
+
url: sessionUrl(port, token),
|
|
1856
|
+
close: () => new Promise((resolvePromise) => {
|
|
1857
|
+
idle.stop();
|
|
1858
|
+
watcher?.close();
|
|
1859
|
+
events.close();
|
|
1860
|
+
for (const socket of sockets) socket.destroy();
|
|
1861
|
+
server.close(() => resolvePromise());
|
|
1862
|
+
})
|
|
1863
|
+
};
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
//#endregion
|
|
1867
|
+
//#region src/server/cli/browser.ts
|
|
1868
|
+
/**
|
|
1869
|
+
* 平台 → 命令的固定映射。**纯函数,便于测试逐条钉住**。
|
|
1870
|
+
*
|
|
1871
|
+
* Windows 那串里的空串不是笔误:它是 `start` 的窗口标题占位,少了它,带引号的 URL
|
|
1872
|
+
* 会被当作标题吞掉,浏览器根本不开(spec §5.1)。
|
|
1873
|
+
*/
|
|
1874
|
+
function browserCommand(platform, url) {
|
|
1875
|
+
if (platform === "darwin") return {
|
|
1876
|
+
command: "open",
|
|
1877
|
+
args: [url]
|
|
1878
|
+
};
|
|
1879
|
+
if (platform === "win32") return {
|
|
1880
|
+
command: "cmd",
|
|
1881
|
+
args: [
|
|
1882
|
+
"/c",
|
|
1883
|
+
"start",
|
|
1884
|
+
"",
|
|
1885
|
+
url
|
|
1886
|
+
]
|
|
1887
|
+
};
|
|
1888
|
+
return {
|
|
1889
|
+
command: "xdg-open",
|
|
1890
|
+
args: [url]
|
|
1891
|
+
};
|
|
1892
|
+
}
|
|
1893
|
+
/**
|
|
1894
|
+
* CI 与冷启动测量里必须能关掉这个调用,否则每跑一次测试就弹一次浏览器(spec §5.10)。
|
|
1895
|
+
*
|
|
1896
|
+
* 这不是「为开发便利而放宽的分支」—— 它不改变任何校验,只决定要不要多起一个进程。
|
|
1897
|
+
*/
|
|
1898
|
+
function browserDisabled(env = process.env) {
|
|
1899
|
+
return Boolean(env.DIFFTAB_NO_OPEN);
|
|
1900
|
+
}
|
|
1901
|
+
/**
|
|
1902
|
+
* 返回是否真的尝试了拉起。
|
|
1903
|
+
*
|
|
1904
|
+
* 失败(没有 `xdg-open`、headless 环境)只打印 URL 让用户自行访问,**不作为启动失败**
|
|
1905
|
+
* (spec §5.1)。URL 在调用本函数之前已经打印过了,所以这里什么都不用补。
|
|
1906
|
+
*/
|
|
1907
|
+
function openBrowser(url) {
|
|
1908
|
+
if (browserDisabled()) return false;
|
|
1909
|
+
const { command, args } = browserCommand(process.platform, url);
|
|
1910
|
+
try {
|
|
1911
|
+
const child = spawn(command, args, {
|
|
1912
|
+
stdio: "ignore",
|
|
1913
|
+
detached: true
|
|
1914
|
+
});
|
|
1915
|
+
child.on("error", () => {});
|
|
1916
|
+
child.unref();
|
|
1917
|
+
return true;
|
|
1918
|
+
} catch {
|
|
1919
|
+
return false;
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
//#endregion
|
|
1924
|
+
//#region src/server/cli/registry.ts
|
|
1925
|
+
/**
|
|
1926
|
+
* 注册表目录。
|
|
1927
|
+
*
|
|
1928
|
+
* **绝不能写进 `.git/` 或工作区** —— 那既污染 `git status`,也实质违背零写操作承诺
|
|
1929
|
+
* (spec §5.8 / §10)。`os.tmpdir()` 的权限因平台而异,Linux 上是 `/tmp`(1777,
|
|
1930
|
+
* 同机其他用户可读),所以这里再套一层每用户私有子目录,文件本身另有 0o600。
|
|
1931
|
+
*/
|
|
1932
|
+
function registryDir() {
|
|
1933
|
+
return join(tmpdir(), "difftab");
|
|
1934
|
+
}
|
|
1935
|
+
/**
|
|
1936
|
+
* 把仓库路径归一成注册表的键。
|
|
1937
|
+
*
|
|
1938
|
+
* 写入侧给的是 `git rev-parse --show-toplevel`,读取侧(dev proxy)给的是
|
|
1939
|
+
* `process.cwd()` —— **两者指向同一个目录时字面量仍可能不同**,而 hash 只认字面量:
|
|
1940
|
+
* - Windows:git 给 `C:/Users/x/repo`,cwd 给 `C:\Users\x\repo`;
|
|
1941
|
+
* - macOS:cwd 经符号链接进来时是 `/var/...`,git 给的是 `/private/var/...`。
|
|
1942
|
+
* 不归一的话 dev proxy 永远找不到正在运行的后端,症状是「后端明明起着却说没找到」。
|
|
1943
|
+
*
|
|
1944
|
+
* **探活比对仓库身份时用的也是这一份**(probe.ts):一侧是 `/api/instance` 返回的
|
|
1945
|
+
* `git rev-parse --show-toplevel`、另一侧是本进程的同一条命令,看着不可能不等 ——
|
|
1946
|
+
* 但 macOS 上 `/tmp` 与 `/private/tmp` 这类差异照样能让两个活着的实例互不相认,
|
|
1947
|
+
* 于是同一个仓库开出第二个进程。两处各写一份归一化,漂移是静默的。
|
|
1948
|
+
*/
|
|
1949
|
+
function normalizeRepoKey(repoRoot) {
|
|
1950
|
+
const abs = resolve(repoRoot);
|
|
1951
|
+
try {
|
|
1952
|
+
return realpathSync.native(abs);
|
|
1953
|
+
} catch {
|
|
1954
|
+
return abs;
|
|
1955
|
+
}
|
|
1956
|
+
}
|
|
1957
|
+
/** 文件名用仓库绝对路径的 hash,避免把路径本身暴露在一个全局可读的目录名里。 */
|
|
1958
|
+
function registryPath(repoRoot) {
|
|
1959
|
+
const hash = createHash("sha256").update(normalizeRepoKey(repoRoot)).digest("hex").slice(0, 32);
|
|
1960
|
+
return join(registryDir(), `${hash}.json`);
|
|
1961
|
+
}
|
|
1962
|
+
/**
|
|
1963
|
+
* 写入注册表。
|
|
1964
|
+
*
|
|
1965
|
+
* `0o600` 必须**配合 `O_EXCL` 在创建时**给出(`'wx'` + mode),而不是先建后 chmod ——
|
|
1966
|
+
* 后者留下一个竞态窗口,窗口里文件是可读的,而它存着本次会话的 token(spec §5.8)。
|
|
1967
|
+
*/
|
|
1968
|
+
function writeExclusive(path, payload) {
|
|
1969
|
+
const fd = openSync(path, "wx", 384);
|
|
1970
|
+
try {
|
|
1971
|
+
writeFileSync(fd, payload);
|
|
1972
|
+
} finally {
|
|
1973
|
+
closeSync(fd);
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
function writeRegistry(entry) {
|
|
1977
|
+
const path = registryPath(entry.repoRoot);
|
|
1978
|
+
mkdirSync(registryDir(), {
|
|
1979
|
+
recursive: true,
|
|
1980
|
+
mode: 448
|
|
1981
|
+
});
|
|
1982
|
+
const payload = JSON.stringify(entry);
|
|
1983
|
+
try {
|
|
1984
|
+
writeExclusive(path, payload);
|
|
1985
|
+
} catch (cause) {
|
|
1986
|
+
if (cause.code !== "EEXIST") throw cause;
|
|
1987
|
+
/**
|
|
1988
|
+
* 走到这里说明**探活已经判过它是陈旧的**(start.ts 命中就不会再启动,更不会
|
|
1989
|
+
* 写注册表),所以覆盖是对的。
|
|
1990
|
+
*
|
|
1991
|
+
* 保留 `O_EXCL` + 显式 unlink 而不是改成 `'w'`:两者的区别只在这个分支要不要
|
|
1992
|
+
* 显式承认「我在覆盖别人的条目」。而 §5.8 那条 `0o600` 必须在创建时给出的要求
|
|
1993
|
+
* 也只有 `'wx'` 满足 —— `'w'` 对已存在的文件根本不套用 mode,一次遗留的
|
|
1994
|
+
* 0644 条目会被原样沿用,里面躺着本次会话的 token。
|
|
1995
|
+
*/
|
|
1996
|
+
unlinkSync(path);
|
|
1997
|
+
writeExclusive(path, payload);
|
|
1998
|
+
}
|
|
1999
|
+
return path;
|
|
2000
|
+
}
|
|
2001
|
+
/**
|
|
2002
|
+
* 读取注册表。dev proxy(见 vite.config.ts)与探活(probe.ts)共用;读不到、或者
|
|
2003
|
+
* 读到一条用不了的条目,一律返回 null。
|
|
2004
|
+
*
|
|
2005
|
+
* **「这条目还能用吗」只在这里判一次**:端口得是一个真能连的端口号、token 不能是
|
|
2006
|
+
* 空串。放在消费侧各判各的话,两个消费者迟早对「可用」有两套定义,而分歧的表现是
|
|
2007
|
+
* dev proxy 与探活对同一条陈旧条目给出不同结论。
|
|
2008
|
+
*/
|
|
2009
|
+
function readRegistry(repoRoot) {
|
|
2010
|
+
try {
|
|
2011
|
+
const raw = readFileSync(registryPath(repoRoot), "utf8");
|
|
2012
|
+
const parsed = JSON.parse(raw);
|
|
2013
|
+
if (typeof parsed.token !== "string" || parsed.token === "") return null;
|
|
2014
|
+
if (!Number.isInteger(parsed.port) || (parsed.port ?? 0) <= 0 || (parsed.port ?? 0) > 65535) return null;
|
|
2015
|
+
return parsed;
|
|
2016
|
+
} catch {
|
|
2017
|
+
return null;
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
/** 退出时清理。只删自己写的那一条,免得把另一个实例的条目带走。 */
|
|
2021
|
+
function removeRegistry(repoRoot) {
|
|
2022
|
+
const path = registryPath(repoRoot);
|
|
2023
|
+
let raw;
|
|
2024
|
+
try {
|
|
2025
|
+
raw = readFileSync(path, "utf8");
|
|
2026
|
+
} catch {
|
|
2027
|
+
return;
|
|
2028
|
+
}
|
|
2029
|
+
let entry = null;
|
|
2030
|
+
try {
|
|
2031
|
+
entry = JSON.parse(raw);
|
|
2032
|
+
} catch {}
|
|
2033
|
+
if (!entry || entry.pid !== process.pid) return;
|
|
2034
|
+
try {
|
|
2035
|
+
unlinkSync(path);
|
|
2036
|
+
} catch {}
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
//#endregion
|
|
2040
|
+
//#region src/server/cli/probe.ts
|
|
2041
|
+
/**
|
|
2042
|
+
* 探活超时。**不取更短**(spec §5.8)。
|
|
2043
|
+
*
|
|
2044
|
+
* 被探的实例可能正卡在 §5.7 那趟用户态递归遍历里(Linux 大仓库上几百毫秒到数秒),
|
|
2045
|
+
* 超时过短的代价不是慢一点,而是**给同一个仓库起了第二个进程**——正是本模块要防的
|
|
2046
|
+
* 那件事。反过来这 1.5s 平时根本走不到:注册表不存在时压根不探(常态,冷启动门禁
|
|
2047
|
+
* 不受影响),端口已死时 localhost 上的 `ECONNREFUSED` 是立即返回的。
|
|
2048
|
+
*/
|
|
2049
|
+
const PROBE_TIMEOUT_MS = 1500;
|
|
2050
|
+
/**
|
|
2051
|
+
* 正文读到这么多还没完就放弃。
|
|
2052
|
+
*
|
|
2053
|
+
* 端口可能已经归了一个完全无关的服务,而它的 `/api/instance` 可以是任何东西 ——
|
|
2054
|
+
* 一个流式响应会让下面那句 `body +=` 一直涨下去。上限之内,一份真正的
|
|
2055
|
+
* `InstanceInfo` 绰绰有余。
|
|
2056
|
+
*/
|
|
2057
|
+
const MAX_BODY_BYTES = 65536;
|
|
2058
|
+
/**
|
|
2059
|
+
* 向记录的端口要一份 `InstanceInfo`。任何不顺利一律返回 null(= 按陈旧处理)。
|
|
2060
|
+
*
|
|
2061
|
+
* **带上 token 与合规的 `Host`**:§5.9 的三道校验一视同仁,探活不是例外 ——
|
|
2062
|
+
* 那也正是「端口已经归了另一个仓库的 difftab」这种情形的判据:它那份 token
|
|
2063
|
+
* 不是我们记下的这个,于是 403、判陈旧,正确。
|
|
2064
|
+
*/
|
|
2065
|
+
function requestInstance(entry, timeoutMs) {
|
|
2066
|
+
return new Promise((resolvePromise) => {
|
|
2067
|
+
const req = request({
|
|
2068
|
+
host: BIND_HOST,
|
|
2069
|
+
port: entry.port,
|
|
2070
|
+
path: "/api/instance",
|
|
2071
|
+
headers: {
|
|
2072
|
+
Host: `${BIND_HOST}:${entry.port}`,
|
|
2073
|
+
Cookie: `${cookieName(entry.port)}=${entry.token}`
|
|
2074
|
+
}
|
|
2075
|
+
}, (res) => {
|
|
2076
|
+
if (res.statusCode !== 200) {
|
|
2077
|
+
req.destroy();
|
|
2078
|
+
resolvePromise(null);
|
|
2079
|
+
return;
|
|
2080
|
+
}
|
|
2081
|
+
/**
|
|
2082
|
+
* **响应头到手之后,`req` 就不再是错误的出口了**(已在 Node 24.14.1 实测)。
|
|
2083
|
+
*
|
|
2084
|
+
* 此后 `req.destroy()` 与对端中途断开都只落在 `res` 上(`'aborted'` 随后
|
|
2085
|
+
* `'error': ECONNRESET`),而 `IncomingMessage` 会把没人听的 `'error'` 自己
|
|
2086
|
+
* 吞掉 —— 于是下面那两处 `destroy()`(超时、正文过大)既走不到 `'end'`、也走
|
|
2087
|
+
* 不到 `req.on('error')`,**这个 Promise 永远不 settle**,`start.ts` 里那句
|
|
2088
|
+
* `await findLiveInstance()` 就把启动整个吊死,没有任何输出。
|
|
2089
|
+
*
|
|
2090
|
+
* `'close'` 是 `res` 上唯一覆盖全部结局的事件;正常读完时它排在 `'end'`
|
|
2091
|
+
* 之后,那时早已 resolve 过,再 resolve 一次没有作用(首次为准)。
|
|
2092
|
+
*/
|
|
2093
|
+
res.on("close", () => resolvePromise(null));
|
|
2094
|
+
let body = "";
|
|
2095
|
+
res.setEncoding("utf8");
|
|
2096
|
+
res.on("data", (chunk) => {
|
|
2097
|
+
body += chunk;
|
|
2098
|
+
if (body.length > MAX_BODY_BYTES) {
|
|
2099
|
+
req.destroy();
|
|
2100
|
+
resolvePromise(null);
|
|
2101
|
+
}
|
|
2102
|
+
});
|
|
2103
|
+
res.on("end", () => {
|
|
2104
|
+
try {
|
|
2105
|
+
const parsed = JSON.parse(body);
|
|
2106
|
+
if (typeof parsed.repoRoot !== "string" || typeof parsed.pid !== "number") {
|
|
2107
|
+
resolvePromise(null);
|
|
2108
|
+
return;
|
|
2109
|
+
}
|
|
2110
|
+
resolvePromise({
|
|
2111
|
+
repoRoot: parsed.repoRoot,
|
|
2112
|
+
pid: parsed.pid
|
|
2113
|
+
});
|
|
2114
|
+
} catch {
|
|
2115
|
+
resolvePromise(null);
|
|
2116
|
+
}
|
|
2117
|
+
});
|
|
2118
|
+
});
|
|
2119
|
+
req.on("error", () => resolvePromise(null));
|
|
2120
|
+
/**
|
|
2121
|
+
* 超时用 `setTimeout` 而不是 `req.setTimeout`:后者只管**socket 空闲**,
|
|
2122
|
+
* 一个每 200ms 吐一个字节的第三方服务能让它永远不触发。这里要的是整次探活的
|
|
2123
|
+
* 墙钟上限。
|
|
2124
|
+
*
|
|
2125
|
+
* **`destroy()` 之后必须自己 resolve**:响应头已经到手时,这一下的错误落在 `res`
|
|
2126
|
+
* 上而不是 `req` 上(见上面那段),光 destroy 等于把超时做成了一个不生效的摆设 ——
|
|
2127
|
+
* 而症状是启动整个吊死,恰好比没有超时更难看出来。
|
|
2128
|
+
*/
|
|
2129
|
+
const timer = setTimeout(() => {
|
|
2130
|
+
req.destroy();
|
|
2131
|
+
resolvePromise(null);
|
|
2132
|
+
}, timeoutMs);
|
|
2133
|
+
req.on("close", () => clearTimeout(timer));
|
|
2134
|
+
req.end();
|
|
2135
|
+
});
|
|
2136
|
+
}
|
|
2137
|
+
/**
|
|
2138
|
+
* 这个仓库已经有活着的实例吗?有就返回它的 URL,没有就 null。
|
|
2139
|
+
*
|
|
2140
|
+
* 命中之后调用方**不得碰注册表**:那条目是另一个进程写的,连「顺手更新一下」都不行
|
|
2141
|
+
* ——它退出时按 pid 只删自己的那条(registry.ts),我们改过的内容会让它留下垃圾。
|
|
2142
|
+
*/
|
|
2143
|
+
async function findLiveInstance(repoRoot, { timeoutMs = PROBE_TIMEOUT_MS } = {}) {
|
|
2144
|
+
const entry = readRegistry(repoRoot);
|
|
2145
|
+
if (!entry) return null;
|
|
2146
|
+
const info = await requestInstance(entry, timeoutMs);
|
|
2147
|
+
if (!info) return null;
|
|
2148
|
+
/**
|
|
2149
|
+
* **200 还不够,路径也得对得上**(spec §5.8)。
|
|
2150
|
+
*
|
|
2151
|
+
* 这一条挡的是「注册表条目还在,而那个端口已经被系统回收给了别的东西」——
|
|
2152
|
+
* 它恰好也认得这份 token 的概率不高,但代价是把用户带到别人的页面,而这类判断
|
|
2153
|
+
* 错了不会有任何东西报错。归一化复用注册表键那一份实现,理由记在那边。
|
|
2154
|
+
*/
|
|
2155
|
+
if (normalizeRepoKey(info.repoRoot) !== normalizeRepoKey(repoRoot)) return null;
|
|
2156
|
+
return {
|
|
2157
|
+
pid: info.pid,
|
|
2158
|
+
url: sessionUrl(entry.port, entry.token)
|
|
2159
|
+
};
|
|
2160
|
+
}
|
|
2161
|
+
|
|
2162
|
+
//#endregion
|
|
2163
|
+
//#region src/server/cli/start.ts
|
|
2164
|
+
async function start(options) {
|
|
2165
|
+
/**
|
|
2166
|
+
* 「可以看了」的唯一出口:**stdout 的第一行是且只是 URL**,随后一行说明,最后
|
|
2167
|
+
* 拉起浏览器。
|
|
2168
|
+
*
|
|
2169
|
+
* 复用与新起两条路各写一遍的话,只有新起那条天天在跑 —— 改了第一行的形状
|
|
2170
|
+
* (加前缀、换写法)会在复用那条上静默漂走,而 `scripts/bench-startup.mjs` 与
|
|
2171
|
+
* 冒烟套件的 ready 判据都压在这一行上(spec §6「ready 的口径」)。
|
|
2172
|
+
*/
|
|
2173
|
+
function announce(url, note) {
|
|
2174
|
+
process.stdout.write(`${url}\n`);
|
|
2175
|
+
process.stdout.write(`${note}\n`);
|
|
2176
|
+
if (!options.noOpen) openBrowser(url);
|
|
2177
|
+
}
|
|
2178
|
+
const repo = await locateRepo(options.cwd);
|
|
2179
|
+
/**
|
|
2180
|
+
* **同一个仓库已经有实例在跑,就把用户送去那一个**(spec §5.8)。
|
|
2181
|
+
*
|
|
2182
|
+
* 判活是 HTTP 探活而不是 pid —— 理由在 probe.ts。命中这条路之后**什么都不写**:
|
|
2183
|
+
* 不写注册表(那条目属于对面那个进程)、不装信号与 exit 处理器(装了会在本进程
|
|
2184
|
+
* 退出时替对面清理掉它的条目,虽然 removeRegistry 按 pid 又挡了一道)。
|
|
2185
|
+
*/
|
|
2186
|
+
const existing = await findLiveInstance(repo.root);
|
|
2187
|
+
if (existing) {
|
|
2188
|
+
announce(existing.url, `difftab: reusing the instance already serving this repository (pid ${existing.pid}).`);
|
|
2189
|
+
return;
|
|
2190
|
+
}
|
|
2191
|
+
const server = await startServer(repo, {
|
|
2192
|
+
/**
|
|
2193
|
+
* 空闲宽限期走满(spec §5.8)。**「怎么退」归这一层**:http/ 那边只知道
|
|
2194
|
+
* 「没人了」,退出码、提示语、以及退出前要不要先 close 都是启动流程的事。
|
|
2195
|
+
*/
|
|
2196
|
+
onIdle: () => stop(0, "difftab: no tabs left — exiting.\n") });
|
|
2197
|
+
writeRegistry({
|
|
2198
|
+
pid: process.pid,
|
|
2199
|
+
port: server.port,
|
|
2200
|
+
token: server.token,
|
|
2201
|
+
repoRoot: repo.root,
|
|
2202
|
+
startedAt: Date.now()
|
|
2203
|
+
});
|
|
2204
|
+
let closing = false;
|
|
2205
|
+
/**
|
|
2206
|
+
* 唯一的退出路径。信号与空闲退出共用它 —— 两者完全可能撞在一起(Ctrl+C 恰好
|
|
2207
|
+
* 落在宽限期走满的同一拍),各写一份的话 `server.close()` 会跑两次,第二次撞上
|
|
2208
|
+
* 已经 destroy 的 socket。
|
|
2209
|
+
*/
|
|
2210
|
+
const stop = (code, note) => {
|
|
2211
|
+
if (closing) return;
|
|
2212
|
+
closing = true;
|
|
2213
|
+
/**
|
|
2214
|
+
* **`writeSync` 而不是 `process.stdout.write`**:后者写到**管道**时在 Windows 上
|
|
2215
|
+
* 是异步的,紧跟着的 `process.exit()` 会把还在缓冲区里的内容整条丢掉(与 main.ts
|
|
2216
|
+
* 那条报错同一个规避手法)。而这句提示正是自动化验证「它是自己走的、不是被
|
|
2217
|
+
* kill 的」的判据,丢了之后症状是 stdout 里什么都没有。
|
|
2218
|
+
*
|
|
2219
|
+
* **而它得能失败**:`writeSync` 与 `process.stdout.write` 不同,读端已经走了
|
|
2220
|
+
* (`difftab --no-open | head -1`)时它**抛 EPIPE**。抛在这个定时器回调里,
|
|
2221
|
+
* `closing` 已经合上,于是 `server.close()` 不再执行,进程带着一屏 Node 栈以 1
|
|
2222
|
+
* 退出 —— 而这条路承诺的是干净的 0。提示没打出去不是错误,是没人在听。
|
|
2223
|
+
*/
|
|
2224
|
+
if (note) try {
|
|
2225
|
+
writeSync(1, note);
|
|
2226
|
+
} catch {}
|
|
2227
|
+
server.close().finally(() => {
|
|
2228
|
+
process.exit(code);
|
|
2229
|
+
});
|
|
2230
|
+
};
|
|
2231
|
+
process.on("SIGINT", () => stop(130));
|
|
2232
|
+
process.on("SIGTERM", () => stop(143));
|
|
2233
|
+
process.on("exit", () => removeRegistry(repo.root));
|
|
2234
|
+
announce(server.url, "difftab: read-only view of this repository. Press Ctrl+C to stop.");
|
|
2235
|
+
}
|
|
2236
|
+
|
|
2237
|
+
//#endregion
|
|
2238
|
+
//#region src/server/main.ts
|
|
2239
|
+
/**
|
|
2240
|
+
* 版本号只有 package.json 一个来源。
|
|
2241
|
+
*
|
|
2242
|
+
* 不把它硬编码在这里,也不在构建期 define 进去:硬编码会在第一次发版改号时
|
|
2243
|
+
* 静默漂移(`--version` 报旧号,没有任何东西会失败);构建期注入则让 `dist/` 与
|
|
2244
|
+
* 源码对不上、调试时更难看清。运行时读一次的代价只落在 `--version` 这条路径上。
|
|
2245
|
+
* dist/server/main.js → 包根的 package.json;`files` 之外 npm 总会带上它。
|
|
2246
|
+
*/
|
|
2247
|
+
function readVersion() {
|
|
2248
|
+
const manifestPath = fileURLToPath(new URL("../../package.json", import.meta.url));
|
|
2249
|
+
return JSON.parse(readFileSync(manifestPath, "utf8")).version;
|
|
2250
|
+
}
|
|
2251
|
+
/** 一句话友好报错,不是 Node 异常栈(spec §5.2 的启动前置检查)。 */
|
|
2252
|
+
function fail(message) {
|
|
2253
|
+
try {
|
|
2254
|
+
writeSync(2, `difftab: ${message}\n`);
|
|
2255
|
+
} catch {}
|
|
2256
|
+
process.exit(1);
|
|
2257
|
+
}
|
|
2258
|
+
/**
|
|
2259
|
+
* 读端先走了不算错误(`difftab --no-open | head -1`:用户只想要那行 URL)。
|
|
2260
|
+
*
|
|
2261
|
+
* 之后每一次写都以 EPIPE 失败,而**在 Windows 上管道写是异步的**(POSIX 上同步),
|
|
2262
|
+
* 失败因此以一个 `'error'` 事件到达 —— 零监听器的流收到 `'error'` 就是整个进程带着
|
|
2263
|
+
* 裸栈以 1 退出。已在 CI 的 windows × Node 24 档实测到:`head` 一退,紧接着那句
|
|
2264
|
+
* 「read-only view of this repository」就把服务打死了,而 macOS / Linux 上同一条路
|
|
2265
|
+
* 一声不响(见 spec §10)。
|
|
2266
|
+
*
|
|
2267
|
+
* 服务本身没坏 —— 浏览器照常用得上,45 秒没人连再自己退,所以这里只是把 EPIPE
|
|
2268
|
+
* 咽掉。**非 EPIPE 的错误照旧抛**:那是真出事了,不该借这条路一起被吞掉。
|
|
2269
|
+
*/
|
|
2270
|
+
function ignoreBrokenPipe(stream) {
|
|
2271
|
+
stream.on("error", (cause) => {
|
|
2272
|
+
if (cause.code !== "EPIPE") throw cause;
|
|
2273
|
+
});
|
|
2274
|
+
}
|
|
2275
|
+
async function main(argv) {
|
|
2276
|
+
ignoreBrokenPipe(process.stdout);
|
|
2277
|
+
ignoreBrokenPipe(process.stderr);
|
|
2278
|
+
let options;
|
|
2279
|
+
try {
|
|
2280
|
+
options = parseCliArgs(argv);
|
|
2281
|
+
} catch (cause) {
|
|
2282
|
+
if (cause instanceof CliUsageError) fail(`${cause.message}\n\n${HELP_TEXT}`);
|
|
2283
|
+
throw cause;
|
|
2284
|
+
}
|
|
2285
|
+
if (options.help) {
|
|
2286
|
+
process.stdout.write(HELP_TEXT);
|
|
2287
|
+
return;
|
|
2288
|
+
}
|
|
2289
|
+
if (options.version) {
|
|
2290
|
+
process.stdout.write(`difftab ${readVersion()}\n`);
|
|
2291
|
+
return;
|
|
2292
|
+
}
|
|
2293
|
+
try {
|
|
2294
|
+
await start({
|
|
2295
|
+
cwd: process.cwd(),
|
|
2296
|
+
noOpen: options.noOpen
|
|
2297
|
+
});
|
|
2298
|
+
} catch (cause) {
|
|
2299
|
+
if (cause instanceof PreflightError || cause instanceof WatchTierError || cause instanceof IdleConfigError) fail(cause.message);
|
|
2300
|
+
throw cause;
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
//#endregion
|
|
2305
|
+
export { main };
|