dsh-pocket-relay 1.0.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.
@@ -0,0 +1,101 @@
1
+ // dsh-pocket relay 线协议(PC 侧 agent 用)
2
+ //
3
+ // 服务端已改为 Go 实现(relay/ 目录,见 relay/PROTOCOL.md)。本文件是**协议在
4
+ // Node 侧的唯一定义**,与 Go 的 relay/internal/protocol 一一对应;两边都改才算
5
+ // 改了协议,权威描述在 relay/PROTOCOL.md。
6
+ //
7
+ // 之所以不共用一份文件:Go 与 Node 之间不存在共享模块,硬凑共享只会让任一端
8
+ // 多一层构建步骤。协议本身很小(3 个帧类型 + 一行 NDJSON + 常量时间比较),
9
+ // 复制一份的维护成本远低于引入代码生成的复杂度。
10
+
11
+ import { timingSafeEqual } from 'node:crypto';
12
+
13
+ /** 协议版本;两端不一致时 relay 直接拒绝,避免半懂不懂地跑。 */
14
+ export const PROTOCOL_VERSION = 1;
15
+
16
+ /** 控制帧类型。 */
17
+ export const T = Object.freeze({
18
+ /** agent → relay:首帧握手 */
19
+ hello: 'hello',
20
+ /** relay → agent:握手通过 */
21
+ welcome: 'welcome',
22
+ /** relay → agent:请为该流开一条数据连接 */
23
+ open: 'open',
24
+ /** 双向:关闭该流 */
25
+ close: 'close',
26
+ /** agent → relay:数据连接首行握手 */
27
+ data: 'data',
28
+ ping: 'ping',
29
+ pong: 'pong',
30
+ /** 任一端:致命错误,随后连接会被关闭 */
31
+ error: 'error',
32
+ });
33
+
34
+ /**
35
+ * 编码一条控制帧。
36
+ * 用 NDJSON 而不是二进制帧:隧道出问题时能在日志/抓包里直接看懂,
37
+ * 而这些帧的吞吐量相比承载的 HTTP 正文可以忽略。
38
+ */
39
+ export function encodeControl(obj) {
40
+ return Buffer.from(`${JSON.stringify(obj)}\n`, 'utf8');
41
+ }
42
+
43
+ /**
44
+ * 增量行切分器,用于「首行是 NDJSON 握手、其后是裸字节流」的协议。
45
+ *
46
+ * - `push(chunk)` → 返回本次切出的完整行数组;未成行的尾部留在内部。
47
+ * - `takeRest()` → 取走未成行的剩余字节。握手行解析完成后调用它,
48
+ * 拿到的就是裸流的开头(**必须**这么做,否则同一 TCP 分片里跟在
49
+ * `\n` 后面的正文会被静默丢掉)。
50
+ *
51
+ * @param {{ maxLine?: number }} [opts] 单行上限,防对端吐无限长行撑爆内存
52
+ */
53
+ export function makeLineSplitter({ maxLine = 64 * 1024 } = {}) {
54
+ let pending = Buffer.alloc(0);
55
+ return {
56
+ push(chunk) {
57
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
58
+ pending = pending.length === 0 ? buf : Buffer.concat([pending, buf]);
59
+ const lines = [];
60
+ let idx;
61
+ while ((idx = pending.indexOf(0x0a)) >= 0) {
62
+ lines.push(pending.subarray(0, idx).toString('utf8'));
63
+ pending = pending.subarray(idx + 1);
64
+ }
65
+ if (pending.length > maxLine) {
66
+ throw new Error(`relay: handshake line too long | 握手行过长(>${maxLine} 字节)`);
67
+ }
68
+ return lines;
69
+ },
70
+ takeRest() {
71
+ const rest = pending;
72
+ pending = Buffer.alloc(0);
73
+ return rest;
74
+ },
75
+ get buffered() {
76
+ return pending.length;
77
+ },
78
+ };
79
+ }
80
+
81
+ /** 解析一行控制 JSON;非法返回 null,由调用方按协议错误处理。 */
82
+ export function parseControl(line) {
83
+ try {
84
+ const value = JSON.parse(String(line));
85
+ return value && typeof value === 'object' ? value : null;
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * 常量时间比较(长度不同或空值一律判否)。
93
+ * 用 `===` 比 token 会在首个不同字节处提前返回,理论上可被计时侧信道逐字节还原
94
+ * ——与 lib/proxy.mjs 的 safeEqual 同一考量。
95
+ */
96
+ export function tokenEquals(a, b) {
97
+ const ba = Buffer.from(String(a ?? ''), 'utf8');
98
+ const bb = Buffer.from(String(b ?? ''), 'utf8');
99
+ if (ba.length === 0 || ba.length !== bb.length) return false;
100
+ return timingSafeEqual(ba, bb);
101
+ }
package/lib/relay.mjs ADDED
@@ -0,0 +1,355 @@
1
+ // dsh-pocket relay 客户端(PC 侧 agent)
2
+ //
3
+ // 跑在 dsh web 进程内,出站长连接到自建 relay 服务器;收到 relay 的 open 后,
4
+ // 为那条访客连接**新建一条数据连接**并把它接到本机 dsh-pocket 代理(默认 3081)。
5
+ //
6
+ // relay(服务器) ──控制连接(长连接)──▶ agent(本模块)
7
+ // ◀──open(id)────────
8
+ // relay ◀──数据连接(每流一条)── agent ──TCP──▶ 127.0.0.1:3081
9
+ //
10
+ // 关键取舍:**只搬字节,不动 HTTP**。认证、Host/Origin 改写、压缩、WS 心跳全部
11
+ // 由原有的 lib/proxy.mjs 负责,所以中继通道天然继承那套安全语义——relay 来的
12
+ // 访客 Host 是公网域名,classifyHost 判为 public,自动强制公网密码。
13
+ //
14
+ // 常开(无人监听)由上层保证:lib/index.js 在插件 apply 时直接 start(),不等用户点。
15
+
16
+ import { connect as netConnect } from 'node:net';
17
+ import { connect as tlsConnect } from 'node:tls';
18
+ import { createHash } from 'node:crypto';
19
+ import { hostname } from 'node:os';
20
+ import { encodeControl, makeLineSplitter, parseControl, T, PROTOCOL_VERSION } from './relay-protocol.mjs';
21
+
22
+ /** 默认重连退避(指数 + 抖动,避免服务器重启时全部客户端同时打过来)。 */
23
+ export const DEFAULT_BACKOFF = { minMs: 1_000, maxMs: 30_000, factor: 2, jitter: 0.2 };
24
+
25
+ /** 默认心跳:25s 一次 ping;90s 收不到任何帧判定链路已死。 */
26
+ export const DEFAULT_HEARTBEAT = { intervalMs: 25_000, timeoutMs: 90_000 };
27
+
28
+ /** agent 名字符集(与 relay 侧 relay/internal/config 的 ValidAgentID 必须一致)。 */
29
+ const AGENT_ID_RE = /^[A-Za-z0-9._-]{1,32}$/;
30
+
31
+ /**
32
+ * 本机默认 agent 名:主机名的安全化形式。
33
+ *
34
+ * 为什么不再用固定的 "default":relay 按名字登记机器,**同名会被当成同一台**
35
+ * (后来者把先到者踢掉)。多台电脑都叫 default 就会互相踢下线、无限重连——
36
+ * 这正是不填名字时看到的现象。主机名天然每台不同,所以拿它当默认值,
37
+ * 用户什么都不用配就能多机共存;想改名就在设置里填「本机名称」。
38
+ */
39
+ export function localAgentId() {
40
+ let host = '';
41
+ try {
42
+ host = hostname() || '';
43
+ } catch {
44
+ // 极少数环境取不到主机名;不能因此让整个 relay 起不来
45
+ }
46
+ const clean = host
47
+ .replace(/[^A-Za-z0-9._-]/g, '-')
48
+ .replace(/^[._-]+|[._-]+$/g, '')
49
+ .slice(0, 32);
50
+ if (clean) return clean;
51
+ // 主机名是非 ASCII(中文机器名很常见),整个被替换掉了。用哈希保证
52
+ // 「非空 + 每台不同」;退化成固定值就又会互相踢,那等于没修。
53
+ const h = createHash('sha256').update(host || `pid-${process.pid}`).digest('hex').slice(0, 8);
54
+ return `pc-${h}`;
55
+ }
56
+
57
+ /** 校验 agent 名(设置页保存前先拦,避免写到服务端才被拒)。 */
58
+ export function isValidAgentId(id) {
59
+ return AGENT_ID_RE.test(String(id ?? ''));
60
+ }
61
+
62
+ /**
63
+ * 创建一个 relay agent(不自动连接;调用 start() 才开始)。
64
+ *
65
+ * @param {object} opts
66
+ * @param {string} opts.host relay 服务器地址
67
+ * @param {number} opts.port relay 的 agent 监听端口
68
+ * @param {boolean} [opts.tls] agent 通道是否走 TLS(**强烈建议 true**)
69
+ * @param {string} [opts.servername] TLS SNI / 证书校验用主机名(默认取 host)
70
+ * @param {boolean} [opts.rejectUnauthorized] 自签证书场景可设 false(有中间人风险)
71
+ * @param {string} opts.token 与 relay 配置里一致的共享密钥
72
+ * @param {string} [opts.agentId] agent 名(relay 多机路由用;默认取主机名)
73
+ * @param {() => number} opts.getTargetPort 实时取本机代理端口(代理会自动顺延端口)
74
+ * @param {string} [opts.targetHost] 本机代理地址(默认 127.0.0.1)
75
+ * @param {object} [opts.backoff] 覆盖 DEFAULT_BACKOFF
76
+ * @param {object} [opts.heartbeat] 覆盖 DEFAULT_HEARTBEAT
77
+ * @param {object} [opts.log] 日志(info/warn/error)
78
+ */
79
+ export function createRelayAgent({
80
+ host,
81
+ port,
82
+ tls = false,
83
+ servername,
84
+ rejectUnauthorized = true,
85
+ token = '',
86
+ agentId = localAgentId(),
87
+ getTargetPort,
88
+ targetHost = '127.0.0.1',
89
+ backoff = {},
90
+ heartbeat = {},
91
+ log = console,
92
+ } = {}) {
93
+ const bo = { ...DEFAULT_BACKOFF, ...backoff };
94
+ const hb = { ...DEFAULT_HEARTBEAT, ...heartbeat };
95
+ const logInfo = (...a) => (log.info ?? log.log).call(log, ...a);
96
+ const logWarn = (...a) => (log.warn ?? log.log).call(log, ...a);
97
+
98
+ /** @type {import('node:net').Socket|null} */
99
+ let control = null;
100
+ /** streamId -> { id, upstream, data } */
101
+ const active = new Map();
102
+ /** 控制连接的 NDJSON 行切分器;每次重连都换新实例(避免上一轮的半截行污染)。 */
103
+ let splitter = makeLineSplitter();
104
+ let running = false;
105
+ let reconnectTimer = null;
106
+ let pingTimer = null;
107
+ let attempt = 0;
108
+ let fatal = null;
109
+ let lastRxAt = 0;
110
+ let status = { phase: 'idle', detail: '', since: null, reconnects: 0, streams: 0, error: '' };
111
+
112
+ function setStatus(phase, detail = '') {
113
+ status = {
114
+ ...status,
115
+ phase,
116
+ detail,
117
+ streams: active.size,
118
+ error: phase === 'error' ? detail : '',
119
+ since: Date.now(),
120
+ };
121
+ }
122
+
123
+ function relaySocketOptions() {
124
+ const base = { host, port };
125
+ if (!tls) return base;
126
+ return {
127
+ ...base,
128
+ servername: servername || host,
129
+ rejectUnauthorized,
130
+ };
131
+ }
132
+
133
+ /** 建一条到 relay 的 socket(控制连接与数据连接共用)。 */
134
+ function dialRelay() {
135
+ return tls ? tlsConnect(relaySocketOptions()) : netConnect(relaySocketOptions());
136
+ }
137
+
138
+ /** 把一条流上下两端都关掉;notify 时顺带告诉 relay(对方主动关就不用回通知)。 */
139
+ function closeStream(id, { notify = true } = {}) {
140
+ const entry = active.get(id);
141
+ if (!entry) return;
142
+ active.delete(id);
143
+ for (const s of [entry.upstream, entry.data]) {
144
+ if (s && !s.destroyed) {
145
+ try { s.destroy(); } catch { /* 已销毁 */ }
146
+ }
147
+ }
148
+ if (notify && control && !control.destroyed) {
149
+ try { control.write(encodeControl({ t: T.close, id })); } catch { /* 忽略 */ }
150
+ }
151
+ status = { ...status, streams: active.size };
152
+ }
153
+
154
+ function closeAllStreams() {
155
+ for (const id of [...active.keys()]) closeStream(id, { notify: false });
156
+ }
157
+
158
+ /**
159
+ * relay 要求为某条访客连接开流:连本机代理 + 建数据连接 + 对接。
160
+ *
161
+ * 顺序很讲究:上游先 pause,等数据连接握手写完再 pipe + resume——
162
+ * 否则上游在这几十毫秒里吐出的响应头会被写进数据连接的**握手行之前**,
163
+ * relay 那边就会把它当成握手 JSON 解析然后断链。
164
+ */
165
+ function openStream(id) {
166
+ if (active.has(id)) return;
167
+ const targetPort = Number(getTargetPort?.() ?? 0);
168
+ if (!Number.isInteger(targetPort) || targetPort <= 0) {
169
+ logWarn('relay-agent: no local proxy port, refusing stream | 本机代理端口未知,拒绝开流');
170
+ if (control && !control.destroyed) {
171
+ try { control.write(encodeControl({ t: T.close, id, reason: 'no-target' })); } catch { /* 忽略 */ }
172
+ }
173
+ return;
174
+ }
175
+
176
+ const upstream = netConnect({ host: targetHost, port: targetPort });
177
+ const entry = { id, upstream, data: null };
178
+ active.set(id, entry);
179
+ status = { ...status, streams: active.size };
180
+
181
+ upstream.on('error', () => closeStream(id, { notify: true }));
182
+ upstream.once('connect', () => {
183
+ upstream.pause();
184
+ const data = dialRelay();
185
+ entry.data = data;
186
+ data.on('error', () => closeStream(id, { notify: false }));
187
+
188
+ const onReady = () => {
189
+ try {
190
+ data.write(encodeControl({ t: T.data, id, agent: agentId, token }));
191
+ } catch {
192
+ closeStream(id, { notify: true });
193
+ return;
194
+ }
195
+ upstream.pipe(data);
196
+ data.pipe(upstream);
197
+ upstream.resume();
198
+ };
199
+ if (tls) data.once('secureConnect', onReady);
200
+ else data.once('connect', onReady);
201
+ });
202
+ }
203
+
204
+ // ---------- 控制连接 ----------
205
+ function handleControlData(chunk) {
206
+ lastRxAt = Date.now();
207
+ let lines;
208
+ try {
209
+ lines = splitter.push(chunk);
210
+ } catch (err) {
211
+ fatal = `relay 控制帧异常:${err?.message ?? err} | protocol error`;
212
+ try { control?.destroy(); } catch { /* 忽略 */ }
213
+ return;
214
+ }
215
+ for (const line of lines) {
216
+ const msg = parseControl(line);
217
+ if (!msg) continue;
218
+ if (msg.t === T.welcome) {
219
+ if (msg.v !== PROTOCOL_VERSION) {
220
+ // 版本不一致是**致命**的:重连一万次也不会好,直接停并说清原因
221
+ fatal = `relay 协议版本不匹配(服务器 ${msg.v},本机 ${PROTOCOL_VERSION})— 请把 relay/ 与插件一起升级 | protocol version mismatch`;
222
+ try { control?.destroy(); } catch { /* 忽略 */ }
223
+ return;
224
+ }
225
+ attempt = 0;
226
+ setStatus('online', `已连接 ${host}:${port}`);
227
+ logInfo(`dsh-pocket-relay: relay connected to ${host}:${port} | 中继已连接`);
228
+ continue;
229
+ }
230
+ if (msg.t === T.open) { openStream(String(msg.id ?? '')); continue; }
231
+ if (msg.t === T.close) { closeStream(String(msg.id ?? ''), { notify: false }); continue; }
232
+ if (msg.t === T.pong) continue;
233
+ if (msg.t === T.error) {
234
+ fatal = `relay 拒绝:${msg.error ?? 'unknown'} | relay rejected`;
235
+ try { control?.destroy(); } catch { /* 忽略 */ }
236
+ return;
237
+ }
238
+ }
239
+ }
240
+
241
+ function scheduleReconnect() {
242
+ if (!running || reconnectTimer !== null) return;
243
+ const base = Math.min(bo.maxMs, bo.minMs * bo.factor ** attempt);
244
+ const jitter = base * bo.jitter * (Math.random() * 2 - 1);
245
+ const delay = Math.max(bo.minMs, Math.round(base + jitter));
246
+ attempt += 1;
247
+ setStatus('connecting', `${Math.round(delay / 1000)} 秒后重连…`);
248
+ reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, delay);
249
+ reconnectTimer.unref?.();
250
+ }
251
+
252
+ function stopHeartbeat() {
253
+ if (pingTimer !== null) { clearInterval(pingTimer); pingTimer = null; }
254
+ }
255
+
256
+ function startHeartbeat() {
257
+ stopHeartbeat();
258
+ pingTimer = setInterval(() => {
259
+ if (!control || control.destroyed) return;
260
+ if (Date.now() - lastRxAt > hb.timeoutMs) {
261
+ // 静默断链(NAT 超时 / 链路被丢):没有 FIN,只能靠心跳发现,主动断开重连
262
+ logWarn('dsh-pocket-relay: relay link silent, reconnecting | 中继链路静默,主动重连');
263
+ try { control.destroy(); } catch { /* 忽略 */ }
264
+ return;
265
+ }
266
+ try { control.write(encodeControl({ t: T.ping, at: Date.now() })); } catch { /* 忽略 */ }
267
+ }, hb.intervalMs);
268
+ pingTimer.unref?.();
269
+ }
270
+
271
+ function connect() {
272
+ if (!running) return;
273
+ if (!host || !Number(port) || !token) {
274
+ setStatus('error', '中继未配置完整(地址 / 端口 / Token) | relay is not configured');
275
+ return;
276
+ }
277
+ setStatus('connecting', `正在连接 ${host}:${port}…`);
278
+ fatal = null;
279
+ splitter = makeLineSplitter();
280
+ lastRxAt = Date.now();
281
+
282
+ const socket = dialRelay();
283
+ control = socket;
284
+ let welcomed = false;
285
+
286
+ socket.on('error', (err) => {
287
+ if (!welcomed && running) {
288
+ status = { ...status, error: err?.message ?? String(err) };
289
+ }
290
+ });
291
+ socket.on('data', (chunk) => {
292
+ // welcome 到达即视为「连接可用」,重连计数归零由 handleControlData 处理
293
+ if (!welcomed) {
294
+ welcomed = true;
295
+ startHeartbeat();
296
+ }
297
+ handleControlData(chunk);
298
+ });
299
+ const onUp = () => {
300
+ try {
301
+ socket.write(encodeControl({ t: T.hello, role: 'agent', agent: agentId, token, v: PROTOCOL_VERSION }));
302
+ } catch {
303
+ try { socket.destroy(); } catch { /* 忽略 */ }
304
+ }
305
+ };
306
+ if (tls) socket.once('secureConnect', onUp);
307
+ else socket.once('connect', onUp);
308
+
309
+ const onGone = () => {
310
+ stopHeartbeat();
311
+ if (control === socket) control = null;
312
+ closeAllStreams();
313
+ if (!running) return;
314
+ if (fatal) {
315
+ // 致命错误(协议不匹配 / token 被拒):不再重连,把原因摆到界面上
316
+ setStatus('error', fatal);
317
+ running = false;
318
+ return;
319
+ }
320
+ status = { ...status, reconnects: status.reconnects + 1 };
321
+ scheduleReconnect();
322
+ };
323
+ socket.on('close', onGone);
324
+ socket.on('error', () => { /* 收尾统一走 close */ });
325
+ }
326
+
327
+ return {
328
+ /** 开始连接(幂等)。 */
329
+ start() {
330
+ if (running) return;
331
+ running = true;
332
+ attempt = 0;
333
+ status = { ...status, reconnects: 0 };
334
+ connect();
335
+ },
336
+ /** 停止并断开一切;再次 start() 可恢复。 */
337
+ stop() {
338
+ running = false;
339
+ fatal = null;
340
+ if (reconnectTimer !== null) { clearTimeout(reconnectTimer); reconnectTimer = null; }
341
+ stopHeartbeat();
342
+ closeAllStreams();
343
+ const c = control;
344
+ control = null;
345
+ if (c && !c.destroyed) {
346
+ try { c.destroy(); } catch { /* 忽略 */ }
347
+ }
348
+ setStatus('stopped', '已停止 | stopped');
349
+ },
350
+ /** 状态快照(设置页展示 / RPC 返回)。 */
351
+ status() {
352
+ return { ...status, streams: active.size, host, port, tls, agentId, running };
353
+ },
354
+ };
355
+ }
package/lib/restart.js ADDED
@@ -0,0 +1,112 @@
1
+ // dsh-pocket 自重启:重新拉起启动本宿主的确切 dsh 调用(detached 交接),
2
+ // 让更新后的插件代码生效——用户无需离开界面手动重启。
3
+ //
4
+ // 方案借鉴 dshmarket 的 self-restart(lib/restart.js,MIT):不直接拉起新
5
+ // 进程,而是先拉一个 detached 的 node 辅助进程,等旧进程退出、端口释放
6
+ // 后再拉起新 dsh,并把新进程输出写入临时日志——避免端口竞争
7
+ // (EADDRINUSE)导致新进程静默崩溃。
8
+ //
9
+ // 与 dshmarket 的差异:不赌固定 1.5s 延时,而是轮询探测端口真正释放
10
+ // (ECONNREFUSED)再拉起,旧进程退出慢也不会撞端口。
11
+ //
12
+ // 注意:新进程 detached,不挂终端——停止方式:lsof -ti :3080 | xargs kill -9。
13
+
14
+ import { spawn } from 'node:child_process';
15
+ import { tmpdir } from 'node:os';
16
+ import { join } from 'node:path';
17
+
18
+ /** 从启动参数里解析 dsh web 端口(--port/-p,含 --port=3080 形式),默认 3080。 */
19
+ export function dshPortFromArgs(args) {
20
+ for (let i = 0; i < args.length; i++) {
21
+ const a = args[i];
22
+ if (a === '--port' || a === '-p') {
23
+ const n = Number(args[i + 1]);
24
+ if (Number.isInteger(n) && n > 0 && n < 65536) return n;
25
+ } else if (a.startsWith('--port=')) {
26
+ const n = Number(a.slice('--port='.length));
27
+ if (Number.isInteger(n) && n > 0 && n < 65536) return n;
28
+ }
29
+ }
30
+ return 3080;
31
+ }
32
+
33
+ /** 重建启动调用(与当前宿主相同的命令,含 node 运行参数)。 */
34
+ export function restartLaunch() {
35
+ return {
36
+ file: process.argv[0], // node
37
+ args: [...process.execArgv, process.argv[1], ...process.argv.slice(2)], // [flags] <bin.js> + web [flags]
38
+ cwd: process.cwd(),
39
+ };
40
+ }
41
+
42
+ /** 辅助进程代码:等 dsh 端口真正释放(最多 20s)→ 拉起新 dsh → 输出写日志。 */
43
+ function helperCode(launch, logOut, logErr, port) {
44
+ const spawn = "const { spawn } = require('node:child_process')";
45
+ const fs = "const fs = require('node:fs')";
46
+ const net = "const net = require('node:net')";
47
+ const file = `const file = ${JSON.stringify(launch.file)}`;
48
+ const args = `const args = ${JSON.stringify(launch.args)}`;
49
+ const cwd = `const cwd = ${JSON.stringify(launch.cwd)}`;
50
+ const o = `const logOut = ${JSON.stringify(logOut)}`;
51
+ const e = `const logErr = ${JSON.stringify(logErr)}`;
52
+ const body = [
53
+ 'function portFree(p, cb) {',
54
+ ' const s = net.connect(p, "127.0.0.1")',
55
+ ' s.once("connect", () => { s.destroy(); cb(false) })', // 还能连上 = 端口仍被占用
56
+ ' s.once("error", () => cb(true))', // 连接被拒 = 已释放
57
+ '}',
58
+ 'function waitPort(p, tries, cb) {',
59
+ ' portFree(p, (free) => {',
60
+ ' if (free || tries <= 0) cb(free)',
61
+ ' else setTimeout(() => waitPort(p, tries - 1, cb), 200)',
62
+ ' })',
63
+ '}',
64
+ `waitPort(${port}, 100, (free) => {`, // 最多 100×200ms = 20s;超时也照常拉起(宁可试拉、日志留痕,也不让 dsh 起不来)
65
+ ' setTimeout(() => {',
66
+ ' try {',
67
+ ' const out = fs.openSync(logOut, "a")',
68
+ ' const err = fs.openSync(logErr, "a")',
69
+ ' const child = spawn(file, args, { cwd, detached: true, stdio: ["ignore", out, err], env: process.env })',
70
+ ' child.unref()',
71
+ ' } catch (ex) {',
72
+ ' try { fs.appendFileSync(logErr, "restart helper failed: " + (ex && ex.message) + "\\n") } catch {}',
73
+ ' }',
74
+ ' }, 300)',
75
+ '})',
76
+ ].join('\n');
77
+ return [spawn, fs, net, file, args, cwd, o, e, body].join('\n');
78
+ }
79
+
80
+ /**
81
+ * 拉起替代宿主(detached 辅助进程交接),随后结束当前进程。
82
+ * @param {object} opts
83
+ * @param {number} [opts.handoffMs] 保留参数(兼容旧调用;现由端口探测接管)
84
+ * @param {object} [opts.internals] 测试注入:spawn / kill
85
+ * @returns {{helperPid:number|null, logOut:string, logErr:string}}
86
+ */
87
+ export function restartHost({ handoffMs = 1500, internals = {} } = {}) {
88
+ const spawnFn = internals.spawn ?? spawn;
89
+ const killFn = internals.kill ?? ((pid) => process.kill(pid, 'SIGTERM'));
90
+ const launch = restartLaunch();
91
+ const port = dshPortFromArgs(launch.args);
92
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
93
+ const logOut = join(tmpdir(), `dsh-pocket-restart-${stamp}.out.log`);
94
+ const logErr = join(tmpdir(), `dsh-pocket-restart-${stamp}.err.log`);
95
+
96
+ let helperPid = null;
97
+ try {
98
+ const helper = spawnFn(process.execPath, ['-e', helperCode(launch, logOut, logErr, port)], {
99
+ detached: true,
100
+ stdio: 'ignore',
101
+ env: process.env,
102
+ });
103
+ helper.unref?.();
104
+ helper.on?.('error', () => {}); // 参数异常等异步错误兜底,别让旧进程先崩
105
+ helperPid = helper.pid ?? null;
106
+ // 短暂等待后结束当前进程(释放端口);由辅助进程探测到端口释放后拉起新宿主
107
+ setTimeout(() => { try { killFn(process.pid); } catch { /* 忽略 */ } }, 500);
108
+ } catch (err) {
109
+ return { helperPid: null, logOut, logErr, error: err?.message ?? String(err) };
110
+ }
111
+ return { helperPid, logOut, logErr };
112
+ }