zen-gitsync 2.11.34 → 2.11.36
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/package.json +1 -1
- package/src/config.js +25 -7
- package/src/ui/public/assets/index-BBe9y-Fa.css +1 -0
- package/src/ui/public/assets/index-DG0XOxf4.js +95 -0
- package/src/ui/public/assets/{ts.worker-Dth06zuC.js → ts.worker-METxwbDZ.js} +1 -16
- package/src/ui/public/assets/vendor-BdheoY37.css +1 -0
- package/src/ui/public/assets/{vendor-BGRFri1B.js → vendor-wOIXN5u7.js} +225 -218
- package/src/ui/public/index.html +4 -4
- package/src/ui/server/index.js +3 -1
- package/src/ui/server/routes/config.js +1141 -904
- package/src/ui/server/routes/git/diff.js +58 -0
- package/src/ui/server/routes/instances.js +24 -24
- package/src/ui/server/utils/instanceRegistry.js +256 -256
- package/src/ui/server/utils/randomStartPort.js +37 -37
- package/src/ui/public/assets/index-CuS7ofwF.css +0 -1
- package/src/ui/public/assets/index-e3eHiWHO.js +0 -94
- package/src/ui/public/assets/vendor-CKD7ZwC_.css +0 -1
|
@@ -264,4 +264,62 @@ export function registerGitDiffRoutes({
|
|
|
264
264
|
});
|
|
265
265
|
}
|
|
266
266
|
});
|
|
267
|
+
|
|
268
|
+
// 批量撤回文件修改(未跟踪删除,已修改 checkout 还原)
|
|
269
|
+
// body: { filePaths: string[] }
|
|
270
|
+
// 返回: { success, count, results: [{ path, success, error? }] }
|
|
271
|
+
app.post('/api/revert_files', async (req, res) => {
|
|
272
|
+
const filePaths = Array.isArray(req.body?.filePaths) ? req.body.filePaths : []
|
|
273
|
+
if (filePaths.length === 0) {
|
|
274
|
+
return res.status(400).json({ success: false, error: '缺少文件路径参数' })
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const results = []
|
|
278
|
+
let successCount = 0
|
|
279
|
+
|
|
280
|
+
for (const filePath of filePaths) {
|
|
281
|
+
try {
|
|
282
|
+
// 检查文件状态:未跟踪 ??、已暂存 A/M/D、已修改(空状态会返回空字符串)
|
|
283
|
+
const { stdout: statusOutput } = await execGitCommand(`git status --porcelain -- "${filePath}"`)
|
|
284
|
+
|
|
285
|
+
// 未跟踪的文件 (??) → 直接删除
|
|
286
|
+
if (statusOutput.startsWith('??')) {
|
|
287
|
+
try {
|
|
288
|
+
await fs.unlink(filePath)
|
|
289
|
+
results.push({ path: filePath, success: true, message: '未跟踪的文件已删除' })
|
|
290
|
+
successCount++
|
|
291
|
+
continue
|
|
292
|
+
} catch (err) {
|
|
293
|
+
results.push({ path: filePath, success: false, error: `删除文件失败: ${err?.message || err}` })
|
|
294
|
+
continue
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// 已暂存的文件,先取消暂存(不影响工作区)
|
|
299
|
+
if (statusOutput.startsWith('A ') || statusOutput.startsWith('M ') || statusOutput.startsWith('D ')) {
|
|
300
|
+
await execGitCommand(`git reset HEAD -- "${filePath}"`)
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// 已修改文件:丢弃工作区修改
|
|
304
|
+
if (statusOutput) {
|
|
305
|
+
await execGitCommand(`git checkout -- "${filePath}"`)
|
|
306
|
+
results.push({ path: filePath, success: true, message: '文件修改已撤回' })
|
|
307
|
+
successCount++
|
|
308
|
+
} else {
|
|
309
|
+
// 文件已无修改(可能在并发中被处理掉了)
|
|
310
|
+
results.push({ path: filePath, success: true, message: '文件无修改' })
|
|
311
|
+
successCount++
|
|
312
|
+
}
|
|
313
|
+
} catch (err) {
|
|
314
|
+
results.push({ path: filePath, success: false, error: err?.message || String(err) })
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
res.json({
|
|
319
|
+
success: true,
|
|
320
|
+
count: filePaths.length,
|
|
321
|
+
successCount,
|
|
322
|
+
results
|
|
323
|
+
})
|
|
324
|
+
});
|
|
267
325
|
}
|
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
// 实例注册表 API 路由
|
|
2
|
-
// 当前只暴露只读列表;停止/启停他人实例属于 out-of-scope(v1 只做跳转导航)
|
|
3
|
-
|
|
4
|
-
export function registerInstancesRoutes({ app, registry, getCurrentInstanceId }) {
|
|
5
|
-
// 获取所有活跃实例(自动 prune 失效条目)
|
|
6
|
-
app.get('/api/instances', async (req, res) => {
|
|
7
|
-
try {
|
|
8
|
-
const instances = await registry.list({ pruneStale: true });
|
|
9
|
-
const currentInstanceId = typeof getCurrentInstanceId === 'function'
|
|
10
|
-
? getCurrentInstanceId()
|
|
11
|
-
: null;
|
|
12
|
-
res.json({
|
|
13
|
-
success: true,
|
|
14
|
-
instances,
|
|
15
|
-
currentInstanceId
|
|
16
|
-
});
|
|
17
|
-
} catch (error) {
|
|
18
|
-
res.status(500).json({
|
|
19
|
-
success: false,
|
|
20
|
-
error: error?.message || String(error)
|
|
21
|
-
});
|
|
22
|
-
}
|
|
23
|
-
});
|
|
24
|
-
}
|
|
1
|
+
// 实例注册表 API 路由
|
|
2
|
+
// 当前只暴露只读列表;停止/启停他人实例属于 out-of-scope(v1 只做跳转导航)
|
|
3
|
+
|
|
4
|
+
export function registerInstancesRoutes({ app, registry, getCurrentInstanceId }) {
|
|
5
|
+
// 获取所有活跃实例(自动 prune 失效条目)
|
|
6
|
+
app.get('/api/instances', async (req, res) => {
|
|
7
|
+
try {
|
|
8
|
+
const instances = await registry.list({ pruneStale: true });
|
|
9
|
+
const currentInstanceId = typeof getCurrentInstanceId === 'function'
|
|
10
|
+
? getCurrentInstanceId()
|
|
11
|
+
: null;
|
|
12
|
+
res.json({
|
|
13
|
+
success: true,
|
|
14
|
+
instances,
|
|
15
|
+
currentInstanceId
|
|
16
|
+
});
|
|
17
|
+
} catch (error) {
|
|
18
|
+
res.status(500).json({
|
|
19
|
+
success: false,
|
|
20
|
+
error: error?.message || String(error)
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
}
|
|
@@ -1,256 +1,256 @@
|
|
|
1
|
-
// 实例注册表工具
|
|
2
|
-
// 维护 ~/.zen-gitsync-instances.json,记录所有正在运行的 GUI 实例
|
|
3
|
-
// 多进程并发写采用 atomic temp+rename + 进程内串行化 Promise 链
|
|
4
|
-
// stale 判定:PID 不存在 或 lastHeartbeat 超过阈值
|
|
5
|
-
|
|
6
|
-
const STALE_MS = 30_000; // 心跳超时阈值(毫秒)
|
|
7
|
-
const WATCH_DEBOUNCE_MS = 100; // fs.watch 防抖时间
|
|
8
|
-
const REGISTRY_VERSION = 1;
|
|
9
|
-
|
|
10
|
-
function isProcessAlive(pid) {
|
|
11
|
-
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
12
|
-
try {
|
|
13
|
-
// 信号 0 仅做存活检查,不真正发送信号
|
|
14
|
-
process.kill(pid, 0);
|
|
15
|
-
return true;
|
|
16
|
-
} catch (err) {
|
|
17
|
-
if (err && (err.code === 'ESRCH' || err.code === 'ENOENT')) {
|
|
18
|
-
return false;
|
|
19
|
-
}
|
|
20
|
-
// EPERM 等情况:进程存在但权限不足,按 alive 处理
|
|
21
|
-
return true;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// 解析项目名:优先 package.json.name,兜底为目录 basename
|
|
26
|
-
async function resolveProjectName(projectPath, fsMod, pathMod) {
|
|
27
|
-
if (!projectPath) return '';
|
|
28
|
-
try {
|
|
29
|
-
const pkgPath = pathMod.join(projectPath, 'package.json');
|
|
30
|
-
const raw = await fsMod.readFile(pkgPath, 'utf-8');
|
|
31
|
-
const pkg = JSON.parse(raw);
|
|
32
|
-
if (pkg && typeof pkg.name === 'string' && pkg.name.trim()) {
|
|
33
|
-
return pkg.name.trim();
|
|
34
|
-
}
|
|
35
|
-
} catch (_) {
|
|
36
|
-
// 读失败或解析失败,兜底
|
|
37
|
-
}
|
|
38
|
-
return pathMod.basename(projectPath);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
export function createInstanceRegistry({ fs: fsMod, path: pathMod, os: osMod, registryPath }) {
|
|
42
|
-
if (!fsMod || !pathMod || !osMod || !registryPath) {
|
|
43
|
-
throw new Error('createInstanceRegistry: 必须提供 fs/path/os/registryPath');
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
// 进程内写串行化:所有 mutate 操作都 await 这条链
|
|
47
|
-
let writeChain = Promise.resolve();
|
|
48
|
-
|
|
49
|
-
function enqueueWrite(task) {
|
|
50
|
-
const next = writeChain.then(task, task);
|
|
51
|
-
// 不让单个失败阻塞后续操作
|
|
52
|
-
writeChain = next.catch(() => {});
|
|
53
|
-
return next;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
async function readAll() {
|
|
57
|
-
try {
|
|
58
|
-
const raw = await fsMod.readFile(registryPath, 'utf-8');
|
|
59
|
-
const parsed = JSON.parse(raw);
|
|
60
|
-
if (parsed && typeof parsed === 'object' && parsed.instances && typeof parsed.instances === 'object') {
|
|
61
|
-
return parsed;
|
|
62
|
-
}
|
|
63
|
-
return { version: REGISTRY_VERSION, instances: {} };
|
|
64
|
-
} catch (err) {
|
|
65
|
-
if (err && err.code === 'ENOENT') {
|
|
66
|
-
return { version: REGISTRY_VERSION, instances: {} };
|
|
67
|
-
}
|
|
68
|
-
console.warn(`[instanceRegistry] 读取注册表失败,按空表处理: ${err?.message || err}`);
|
|
69
|
-
return { version: REGISTRY_VERSION, instances: {} };
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
async function writeAll(obj) {
|
|
74
|
-
const payload = {
|
|
75
|
-
version: REGISTRY_VERSION,
|
|
76
|
-
...obj,
|
|
77
|
-
instances: obj.instances || {}
|
|
78
|
-
};
|
|
79
|
-
const tmpPath = `${registryPath}.tmp`;
|
|
80
|
-
await fsMod.writeFile(tmpPath, JSON.stringify(payload, null, 2), 'utf-8');
|
|
81
|
-
await fsMod.rename(tmpPath, registryPath);
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// 同步裁剪:传入当前内存中的 instances 字典,返回裁剪后的新字典
|
|
85
|
-
function pruneInPlace(instances) {
|
|
86
|
-
const now = Date.now();
|
|
87
|
-
const result = {};
|
|
88
|
-
for (const [pidStr, entry] of Object.entries(instances)) {
|
|
89
|
-
if (!entry || typeof entry !== 'object') continue;
|
|
90
|
-
const pid = Number(entry.pid ?? Number(pidStr));
|
|
91
|
-
if (!isProcessAlive(pid)) continue;
|
|
92
|
-
if (typeof entry.lastHeartbeat === 'number' && now - entry.lastHeartbeat > STALE_MS) continue;
|
|
93
|
-
result[pidStr] = entry;
|
|
94
|
-
}
|
|
95
|
-
return result;
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// 公开 API
|
|
99
|
-
async function register({ pid, port, projectPath, projectName, hostname } = {}) {
|
|
100
|
-
if (!Number.isInteger(pid) || pid <= 0) throw new Error('register: pid 必填');
|
|
101
|
-
if (!Number.isInteger(port) || port <= 0) throw new Error('register: port 必填');
|
|
102
|
-
if (!projectPath) throw new Error('register: projectPath 必填');
|
|
103
|
-
|
|
104
|
-
const resolvedName = projectName && String(projectName).trim()
|
|
105
|
-
? String(projectName).trim()
|
|
106
|
-
: await resolveProjectName(projectPath, fsMod, pathMod);
|
|
107
|
-
|
|
108
|
-
const entry = {
|
|
109
|
-
pid,
|
|
110
|
-
port,
|
|
111
|
-
projectName: resolvedName,
|
|
112
|
-
projectPath,
|
|
113
|
-
startedAt: Date.now(),
|
|
114
|
-
lastHeartbeat: Date.now(),
|
|
115
|
-
hostname: hostname || osMod.hostname()
|
|
116
|
-
};
|
|
117
|
-
|
|
118
|
-
await enqueueWrite(async () => {
|
|
119
|
-
const obj = await readAll();
|
|
120
|
-
obj.instances[String(pid)] = entry;
|
|
121
|
-
await writeAll(obj);
|
|
122
|
-
});
|
|
123
|
-
return entry;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
async function unregister(pid) {
|
|
127
|
-
if (!Number.isInteger(pid) || pid <= 0) return;
|
|
128
|
-
await enqueueWrite(async () => {
|
|
129
|
-
const obj = await readAll();
|
|
130
|
-
if (obj.instances && obj.instances[String(pid)]) {
|
|
131
|
-
delete obj.instances[String(pid)];
|
|
132
|
-
await writeAll(obj);
|
|
133
|
-
}
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
async function heartbeat(pid, updates = {}) {
|
|
138
|
-
if (!Number.isInteger(pid) || pid <= 0) return;
|
|
139
|
-
await enqueueWrite(async () => {
|
|
140
|
-
const obj = await readAll();
|
|
141
|
-
const key = String(pid);
|
|
142
|
-
const existing = obj.instances[key];
|
|
143
|
-
if (!existing) {
|
|
144
|
-
// 如果心跳时条目不存在(被裁剪或被外部清理),跳过;
|
|
145
|
-
// 由调用方负责周期性 re-register
|
|
146
|
-
return;
|
|
147
|
-
}
|
|
148
|
-
obj.instances[key] = {
|
|
149
|
-
...existing,
|
|
150
|
-
...(updates.projectPath ? { projectPath: updates.projectPath } : {}),
|
|
151
|
-
...(updates.projectName ? { projectName: updates.projectName } : {}),
|
|
152
|
-
lastHeartbeat: Date.now()
|
|
153
|
-
};
|
|
154
|
-
await writeAll(obj);
|
|
155
|
-
});
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
async function list({ pruneStale = true } = {}) {
|
|
159
|
-
const obj = await readAll();
|
|
160
|
-
let instances = obj.instances || {};
|
|
161
|
-
if (pruneStale) {
|
|
162
|
-
instances = pruneInPlace(instances);
|
|
163
|
-
// 如果发生了裁剪,持久化回去
|
|
164
|
-
const hasChange = Object.keys(instances).length !== Object.keys(obj.instances || {}).length;
|
|
165
|
-
if (hasChange) {
|
|
166
|
-
await enqueueWrite(async () => {
|
|
167
|
-
const fresh = await readAll();
|
|
168
|
-
fresh.instances = pruneInPlace(fresh.instances || {});
|
|
169
|
-
await writeAll(fresh);
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
const arr = Object.values(instances);
|
|
174
|
-
arr.sort((a, b) => (a.port || 0) - (b.port || 0));
|
|
175
|
-
return arr;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// 监听注册表文件变化;callback 会在 debounce 后被调用,参数是最新 list
|
|
179
|
-
// fsWatch 参数:node 'fs' 模块的 watch 函数(同步 + EventEmitter 形式)
|
|
180
|
-
function watch(callback, fsWatch) {
|
|
181
|
-
if (typeof callback !== 'function') {
|
|
182
|
-
throw new Error('watch: callback 必填');
|
|
183
|
-
}
|
|
184
|
-
if (typeof fsWatch !== 'function') {
|
|
185
|
-
console.warn('[instanceRegistry] 未提供 fs.watch,跨进程推送将不可用');
|
|
186
|
-
return function noop() {};
|
|
187
|
-
}
|
|
188
|
-
let debounceTimer = null;
|
|
189
|
-
let watcher = null;
|
|
190
|
-
let pruneTimer = null;
|
|
191
|
-
let closed = false;
|
|
192
|
-
|
|
193
|
-
const fire = async () => {
|
|
194
|
-
if (closed) return;
|
|
195
|
-
try {
|
|
196
|
-
const fresh = await list({ pruneStale: true });
|
|
197
|
-
callback(fresh);
|
|
198
|
-
} catch (e) {
|
|
199
|
-
console.warn(`[instanceRegistry] watch 回调失败: ${e?.message || e}`);
|
|
200
|
-
}
|
|
201
|
-
};
|
|
202
|
-
|
|
203
|
-
// 周期性 prune:即便没有其他进程写入注册表,也定期清理本地失效条目
|
|
204
|
-
// (例如所有 server 都强 kill 后,文件里残留的僵尸条目会被自动清理)
|
|
205
|
-
pruneTimer = setInterval(() => {
|
|
206
|
-
if (closed) return;
|
|
207
|
-
list({ pruneStale: true }).catch(() => {});
|
|
208
|
-
}, STALE_MS / 3);
|
|
209
|
-
|
|
210
|
-
try {
|
|
211
|
-
watcher = fsWatch(registryPath, { persistent: false }, () => {
|
|
212
|
-
if (closed) return;
|
|
213
|
-
if (debounceTimer) clearTimeout(debounceTimer);
|
|
214
|
-
debounceTimer = setTimeout(fire, WATCH_DEBOUNCE_MS);
|
|
215
|
-
});
|
|
216
|
-
if (watcher && typeof watcher.on === 'function') {
|
|
217
|
-
watcher.on('error', (err) => {
|
|
218
|
-
console.warn(`[instanceRegistry] fs.watch 出错: ${err?.message || err}`);
|
|
219
|
-
});
|
|
220
|
-
}
|
|
221
|
-
} catch (err) {
|
|
222
|
-
console.warn(`[instanceRegistry] 无法启动 fs.watch (${err?.message || err}),跨进程推送将不可用,请依赖轮询`);
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
return function closeWatcher() {
|
|
226
|
-
closed = true;
|
|
227
|
-
if (debounceTimer) {
|
|
228
|
-
clearTimeout(debounceTimer);
|
|
229
|
-
debounceTimer = null;
|
|
230
|
-
}
|
|
231
|
-
if (pruneTimer) {
|
|
232
|
-
clearInterval(pruneTimer);
|
|
233
|
-
pruneTimer = null;
|
|
234
|
-
}
|
|
235
|
-
if (watcher) {
|
|
236
|
-
try { watcher.close(); } catch (_) {}
|
|
237
|
-
watcher = null;
|
|
238
|
-
}
|
|
239
|
-
};
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
function close() {
|
|
243
|
-
// 工厂内部无 timer,仅为 API 对称
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
return {
|
|
247
|
-
register,
|
|
248
|
-
unregister,
|
|
249
|
-
heartbeat,
|
|
250
|
-
list,
|
|
251
|
-
watch,
|
|
252
|
-
close,
|
|
253
|
-
_resolveProjectName: (p) => resolveProjectName(p, fsMod, pathMod),
|
|
254
|
-
_STALE_MS: STALE_MS
|
|
255
|
-
};
|
|
256
|
-
}
|
|
1
|
+
// 实例注册表工具
|
|
2
|
+
// 维护 ~/.zen-gitsync-instances.json,记录所有正在运行的 GUI 实例
|
|
3
|
+
// 多进程并发写采用 atomic temp+rename + 进程内串行化 Promise 链
|
|
4
|
+
// stale 判定:PID 不存在 或 lastHeartbeat 超过阈值
|
|
5
|
+
|
|
6
|
+
const STALE_MS = 30_000; // 心跳超时阈值(毫秒)
|
|
7
|
+
const WATCH_DEBOUNCE_MS = 100; // fs.watch 防抖时间
|
|
8
|
+
const REGISTRY_VERSION = 1;
|
|
9
|
+
|
|
10
|
+
function isProcessAlive(pid) {
|
|
11
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
12
|
+
try {
|
|
13
|
+
// 信号 0 仅做存活检查,不真正发送信号
|
|
14
|
+
process.kill(pid, 0);
|
|
15
|
+
return true;
|
|
16
|
+
} catch (err) {
|
|
17
|
+
if (err && (err.code === 'ESRCH' || err.code === 'ENOENT')) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
// EPERM 等情况:进程存在但权限不足,按 alive 处理
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// 解析项目名:优先 package.json.name,兜底为目录 basename
|
|
26
|
+
async function resolveProjectName(projectPath, fsMod, pathMod) {
|
|
27
|
+
if (!projectPath) return '';
|
|
28
|
+
try {
|
|
29
|
+
const pkgPath = pathMod.join(projectPath, 'package.json');
|
|
30
|
+
const raw = await fsMod.readFile(pkgPath, 'utf-8');
|
|
31
|
+
const pkg = JSON.parse(raw);
|
|
32
|
+
if (pkg && typeof pkg.name === 'string' && pkg.name.trim()) {
|
|
33
|
+
return pkg.name.trim();
|
|
34
|
+
}
|
|
35
|
+
} catch (_) {
|
|
36
|
+
// 读失败或解析失败,兜底
|
|
37
|
+
}
|
|
38
|
+
return pathMod.basename(projectPath);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createInstanceRegistry({ fs: fsMod, path: pathMod, os: osMod, registryPath }) {
|
|
42
|
+
if (!fsMod || !pathMod || !osMod || !registryPath) {
|
|
43
|
+
throw new Error('createInstanceRegistry: 必须提供 fs/path/os/registryPath');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 进程内写串行化:所有 mutate 操作都 await 这条链
|
|
47
|
+
let writeChain = Promise.resolve();
|
|
48
|
+
|
|
49
|
+
function enqueueWrite(task) {
|
|
50
|
+
const next = writeChain.then(task, task);
|
|
51
|
+
// 不让单个失败阻塞后续操作
|
|
52
|
+
writeChain = next.catch(() => {});
|
|
53
|
+
return next;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function readAll() {
|
|
57
|
+
try {
|
|
58
|
+
const raw = await fsMod.readFile(registryPath, 'utf-8');
|
|
59
|
+
const parsed = JSON.parse(raw);
|
|
60
|
+
if (parsed && typeof parsed === 'object' && parsed.instances && typeof parsed.instances === 'object') {
|
|
61
|
+
return parsed;
|
|
62
|
+
}
|
|
63
|
+
return { version: REGISTRY_VERSION, instances: {} };
|
|
64
|
+
} catch (err) {
|
|
65
|
+
if (err && err.code === 'ENOENT') {
|
|
66
|
+
return { version: REGISTRY_VERSION, instances: {} };
|
|
67
|
+
}
|
|
68
|
+
console.warn(`[instanceRegistry] 读取注册表失败,按空表处理: ${err?.message || err}`);
|
|
69
|
+
return { version: REGISTRY_VERSION, instances: {} };
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
async function writeAll(obj) {
|
|
74
|
+
const payload = {
|
|
75
|
+
version: REGISTRY_VERSION,
|
|
76
|
+
...obj,
|
|
77
|
+
instances: obj.instances || {}
|
|
78
|
+
};
|
|
79
|
+
const tmpPath = `${registryPath}.tmp`;
|
|
80
|
+
await fsMod.writeFile(tmpPath, JSON.stringify(payload, null, 2), 'utf-8');
|
|
81
|
+
await fsMod.rename(tmpPath, registryPath);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// 同步裁剪:传入当前内存中的 instances 字典,返回裁剪后的新字典
|
|
85
|
+
function pruneInPlace(instances) {
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
const result = {};
|
|
88
|
+
for (const [pidStr, entry] of Object.entries(instances)) {
|
|
89
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
90
|
+
const pid = Number(entry.pid ?? Number(pidStr));
|
|
91
|
+
if (!isProcessAlive(pid)) continue;
|
|
92
|
+
if (typeof entry.lastHeartbeat === 'number' && now - entry.lastHeartbeat > STALE_MS) continue;
|
|
93
|
+
result[pidStr] = entry;
|
|
94
|
+
}
|
|
95
|
+
return result;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// 公开 API
|
|
99
|
+
async function register({ pid, port, projectPath, projectName, hostname } = {}) {
|
|
100
|
+
if (!Number.isInteger(pid) || pid <= 0) throw new Error('register: pid 必填');
|
|
101
|
+
if (!Number.isInteger(port) || port <= 0) throw new Error('register: port 必填');
|
|
102
|
+
if (!projectPath) throw new Error('register: projectPath 必填');
|
|
103
|
+
|
|
104
|
+
const resolvedName = projectName && String(projectName).trim()
|
|
105
|
+
? String(projectName).trim()
|
|
106
|
+
: await resolveProjectName(projectPath, fsMod, pathMod);
|
|
107
|
+
|
|
108
|
+
const entry = {
|
|
109
|
+
pid,
|
|
110
|
+
port,
|
|
111
|
+
projectName: resolvedName,
|
|
112
|
+
projectPath,
|
|
113
|
+
startedAt: Date.now(),
|
|
114
|
+
lastHeartbeat: Date.now(),
|
|
115
|
+
hostname: hostname || osMod.hostname()
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
await enqueueWrite(async () => {
|
|
119
|
+
const obj = await readAll();
|
|
120
|
+
obj.instances[String(pid)] = entry;
|
|
121
|
+
await writeAll(obj);
|
|
122
|
+
});
|
|
123
|
+
return entry;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function unregister(pid) {
|
|
127
|
+
if (!Number.isInteger(pid) || pid <= 0) return;
|
|
128
|
+
await enqueueWrite(async () => {
|
|
129
|
+
const obj = await readAll();
|
|
130
|
+
if (obj.instances && obj.instances[String(pid)]) {
|
|
131
|
+
delete obj.instances[String(pid)];
|
|
132
|
+
await writeAll(obj);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
async function heartbeat(pid, updates = {}) {
|
|
138
|
+
if (!Number.isInteger(pid) || pid <= 0) return;
|
|
139
|
+
await enqueueWrite(async () => {
|
|
140
|
+
const obj = await readAll();
|
|
141
|
+
const key = String(pid);
|
|
142
|
+
const existing = obj.instances[key];
|
|
143
|
+
if (!existing) {
|
|
144
|
+
// 如果心跳时条目不存在(被裁剪或被外部清理),跳过;
|
|
145
|
+
// 由调用方负责周期性 re-register
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
obj.instances[key] = {
|
|
149
|
+
...existing,
|
|
150
|
+
...(updates.projectPath ? { projectPath: updates.projectPath } : {}),
|
|
151
|
+
...(updates.projectName ? { projectName: updates.projectName } : {}),
|
|
152
|
+
lastHeartbeat: Date.now()
|
|
153
|
+
};
|
|
154
|
+
await writeAll(obj);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function list({ pruneStale = true } = {}) {
|
|
159
|
+
const obj = await readAll();
|
|
160
|
+
let instances = obj.instances || {};
|
|
161
|
+
if (pruneStale) {
|
|
162
|
+
instances = pruneInPlace(instances);
|
|
163
|
+
// 如果发生了裁剪,持久化回去
|
|
164
|
+
const hasChange = Object.keys(instances).length !== Object.keys(obj.instances || {}).length;
|
|
165
|
+
if (hasChange) {
|
|
166
|
+
await enqueueWrite(async () => {
|
|
167
|
+
const fresh = await readAll();
|
|
168
|
+
fresh.instances = pruneInPlace(fresh.instances || {});
|
|
169
|
+
await writeAll(fresh);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
const arr = Object.values(instances);
|
|
174
|
+
arr.sort((a, b) => (a.port || 0) - (b.port || 0));
|
|
175
|
+
return arr;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 监听注册表文件变化;callback 会在 debounce 后被调用,参数是最新 list
|
|
179
|
+
// fsWatch 参数:node 'fs' 模块的 watch 函数(同步 + EventEmitter 形式)
|
|
180
|
+
function watch(callback, fsWatch) {
|
|
181
|
+
if (typeof callback !== 'function') {
|
|
182
|
+
throw new Error('watch: callback 必填');
|
|
183
|
+
}
|
|
184
|
+
if (typeof fsWatch !== 'function') {
|
|
185
|
+
console.warn('[instanceRegistry] 未提供 fs.watch,跨进程推送将不可用');
|
|
186
|
+
return function noop() {};
|
|
187
|
+
}
|
|
188
|
+
let debounceTimer = null;
|
|
189
|
+
let watcher = null;
|
|
190
|
+
let pruneTimer = null;
|
|
191
|
+
let closed = false;
|
|
192
|
+
|
|
193
|
+
const fire = async () => {
|
|
194
|
+
if (closed) return;
|
|
195
|
+
try {
|
|
196
|
+
const fresh = await list({ pruneStale: true });
|
|
197
|
+
callback(fresh);
|
|
198
|
+
} catch (e) {
|
|
199
|
+
console.warn(`[instanceRegistry] watch 回调失败: ${e?.message || e}`);
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// 周期性 prune:即便没有其他进程写入注册表,也定期清理本地失效条目
|
|
204
|
+
// (例如所有 server 都强 kill 后,文件里残留的僵尸条目会被自动清理)
|
|
205
|
+
pruneTimer = setInterval(() => {
|
|
206
|
+
if (closed) return;
|
|
207
|
+
list({ pruneStale: true }).catch(() => {});
|
|
208
|
+
}, STALE_MS / 3);
|
|
209
|
+
|
|
210
|
+
try {
|
|
211
|
+
watcher = fsWatch(registryPath, { persistent: false }, () => {
|
|
212
|
+
if (closed) return;
|
|
213
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
214
|
+
debounceTimer = setTimeout(fire, WATCH_DEBOUNCE_MS);
|
|
215
|
+
});
|
|
216
|
+
if (watcher && typeof watcher.on === 'function') {
|
|
217
|
+
watcher.on('error', (err) => {
|
|
218
|
+
console.warn(`[instanceRegistry] fs.watch 出错: ${err?.message || err}`);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
} catch (err) {
|
|
222
|
+
console.warn(`[instanceRegistry] 无法启动 fs.watch (${err?.message || err}),跨进程推送将不可用,请依赖轮询`);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return function closeWatcher() {
|
|
226
|
+
closed = true;
|
|
227
|
+
if (debounceTimer) {
|
|
228
|
+
clearTimeout(debounceTimer);
|
|
229
|
+
debounceTimer = null;
|
|
230
|
+
}
|
|
231
|
+
if (pruneTimer) {
|
|
232
|
+
clearInterval(pruneTimer);
|
|
233
|
+
pruneTimer = null;
|
|
234
|
+
}
|
|
235
|
+
if (watcher) {
|
|
236
|
+
try { watcher.close(); } catch (_) {}
|
|
237
|
+
watcher = null;
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function close() {
|
|
243
|
+
// 工厂内部无 timer,仅为 API 对称
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
register,
|
|
248
|
+
unregister,
|
|
249
|
+
heartbeat,
|
|
250
|
+
list,
|
|
251
|
+
watch,
|
|
252
|
+
close,
|
|
253
|
+
_resolveProjectName: (p) => resolveProjectName(p, fsMod, pathMod),
|
|
254
|
+
_STALE_MS: STALE_MS
|
|
255
|
+
};
|
|
256
|
+
}
|