@brierb/brier-cli 0.0.7 → 0.0.9

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.
Files changed (63) hide show
  1. package/dist/commands/log.js +28 -0
  2. package/dist/commands/restart.js +9 -4
  3. package/dist/commands/run.js +13 -0
  4. package/dist/commands/start.js +2 -2
  5. package/dist/commands/status.js +11 -16
  6. package/dist/commands/stop.js +1 -1
  7. package/dist/config/credentials.js +25 -0
  8. package/dist/config/index.js +16 -0
  9. package/dist/config/load.js +26 -0
  10. package/dist/config/paths.js +14 -0
  11. package/dist/config/version.js +13 -0
  12. package/dist/core/index.js +11 -0
  13. package/dist/{runtimes.js → core/runtimes.js} +6 -0
  14. package/dist/daemon/DaemonManager.js +65 -66
  15. package/dist/daemon/DaemonRunner.js +97 -39
  16. package/dist/daemon/OutputBatcher.js +77 -0
  17. package/dist/daemon/TaskExecutor.js +72 -8
  18. package/dist/daemon/index.js +13 -0
  19. package/dist/daemon/state.js +100 -0
  20. package/dist/definitions/daemon.js +5 -0
  21. package/dist/definitions/index.js +12 -0
  22. package/dist/definitions/tunnel.js +9 -0
  23. package/dist/index.js +12 -31
  24. package/dist/tunnel/backoff.js +21 -0
  25. package/dist/tunnel/dispatcher.js +35 -0
  26. package/dist/tunnel/heartbeat.js +55 -0
  27. package/dist/tunnel/index.js +220 -0
  28. package/dist/tunnel/transport.js +112 -0
  29. package/dist/tunnel/url.js +10 -0
  30. package/package.json +3 -3
  31. package/types/commands/log.d.ts +5 -0
  32. package/types/commands/run.d.ts +5 -0
  33. package/types/commands/status.d.ts +1 -0
  34. package/types/config/credentials.d.ts +8 -0
  35. package/types/config/index.d.ts +16 -0
  36. package/types/config/load.d.ts +7 -0
  37. package/types/config/paths.d.ts +12 -0
  38. package/types/config/version.d.ts +2 -0
  39. package/types/core/index.d.ts +11 -0
  40. package/types/{runtimes.d.ts → core/runtimes.d.ts} +6 -0
  41. package/types/daemon/DaemonManager.d.ts +1 -1
  42. package/types/daemon/OutputBatcher.d.ts +39 -0
  43. package/types/daemon/TaskExecutor.d.ts +4 -2
  44. package/types/daemon/index.d.ts +13 -0
  45. package/types/daemon/state.d.ts +19 -0
  46. package/types/definitions/daemon.d.ts +58 -0
  47. package/types/definitions/index.d.ts +17 -0
  48. package/types/definitions/task.d.ts +25 -0
  49. package/types/definitions/tunnel.d.ts +112 -0
  50. package/types/tunnel/backoff.d.ts +32 -0
  51. package/types/tunnel/dispatcher.d.ts +26 -0
  52. package/types/tunnel/heartbeat.d.ts +36 -0
  53. package/types/tunnel/index.d.ts +38 -0
  54. package/types/tunnel/transport.d.ts +29 -0
  55. package/types/tunnel/url.d.ts +5 -0
  56. package/dist/config.js +0 -45
  57. package/dist/tunnel/TunnelClient.js +0 -218
  58. package/types/config.d.ts +0 -9
  59. package/types/tunnel/TunnelClient.d.ts +0 -10
  60. package/types/types.d.ts +0 -78
  61. /package/dist/{logger.js → core/logger.js} +0 -0
  62. /package/dist/{types.js → definitions/task.js} +0 -0
  63. /package/types/{logger.d.ts → core/logger.d.ts} +0 -0
@@ -1,9 +1,36 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { logger } from '../logger.js';
3
- import { resolveRuntimeExecutable, RUNTIME_PROMPT_FLAGS } from '../runtimes.js';
2
+ import { logger, resolveRuntimeExecutable, RUNTIME_PROMPT_FLAGS } from '../core/index.js';
4
3
  const MAX_CONCURRENT = 3;
4
+ /** 取消后等待 SIGTERM 生效的时间,超时升级 SIGKILL */
5
+ const KILL_GRACE_MS = 2_000;
6
+ /** dispose 整体等待上限 */
7
+ const DISPOSE_TIMEOUT_MS = 2_000;
8
+ const DISPOSE_POLL_MS = 100;
5
9
  export const createTaskExecutor = (callbacks) => {
6
10
  const processes = new Map();
11
+ /** 已请求取消的任务:其 close 不再上报 complete */
12
+ const cancelRequested = new Set();
13
+ /** 已上报终态(error/complete 二选一,防双重上报)的任务 */
14
+ const finished = new Set();
15
+ /** 取消后的 SIGKILL 升级计时器 */
16
+ const killTimers = new Map();
17
+ const clearKillTimer = (taskId) => {
18
+ const timer = killTimers.get(taskId);
19
+ if (timer) {
20
+ clearTimeout(timer);
21
+ killTimers.delete(taskId);
22
+ }
23
+ };
24
+ const armKillTimer = (taskId, child, delayMs) => {
25
+ clearKillTimer(taskId);
26
+ killTimers.set(taskId, setTimeout(() => {
27
+ killTimers.delete(taskId);
28
+ if (processes.get(taskId) === child) {
29
+ logger.warn(`Task ${taskId} did not exit after SIGTERM, sending SIGKILL`);
30
+ child.kill('SIGKILL');
31
+ }
32
+ }, delayMs));
33
+ };
7
34
  /**
8
35
  * 解析要执行的命令:
9
36
  * - 显式 command:直接用(调用方负责其可执行性)
@@ -64,12 +91,26 @@ export const createTaskExecutor = (callbacks) => {
64
91
  callbacks.onOutput(task.taskId, 'stderr', data.toString());
65
92
  });
66
93
  child.on('error', (err) => {
94
+ // error 后可能仍触发 close:终态只上报一次
95
+ if (finished.has(task.taskId))
96
+ return;
97
+ finished.add(task.taskId);
98
+ clearKillTimer(task.taskId);
67
99
  processes.delete(task.taskId);
68
100
  logger.error(`Task ${task.taskId} process error:`, err.message);
69
101
  callbacks.onError(task.taskId, err.message);
70
102
  });
71
103
  child.on('close', (code) => {
104
+ if (finished.has(task.taskId))
105
+ return;
106
+ finished.add(task.taskId);
107
+ clearKillTimer(task.taskId);
72
108
  processes.delete(task.taskId);
109
+ if (cancelRequested.has(task.taskId)) {
110
+ cancelRequested.delete(task.taskId);
111
+ logger.info(`Task ${task.taskId} cancelled`);
112
+ return;
113
+ }
73
114
  const exitCode = code ?? 0;
74
115
  logger.info(`Task ${task.taskId} completed with exit code ${exitCode}`);
75
116
  callbacks.onComplete(task.taskId, exitCode);
@@ -81,16 +122,39 @@ export const createTaskExecutor = (callbacks) => {
81
122
  logger.warn(`Task ${taskId} not found, cannot cancel`);
82
123
  return;
83
124
  }
125
+ if (cancelRequested.has(taskId)) {
126
+ return;
127
+ }
128
+ cancelRequested.add(taskId);
129
+ logger.info(`Task ${taskId} cancelling (SIGTERM)`);
84
130
  child.kill('SIGTERM');
85
- processes.delete(taskId);
86
- logger.info(`Task ${taskId} cancelled`);
131
+ armKillTimer(taskId, child, KILL_GRACE_MS);
87
132
  };
88
- const cancelAll = () => {
133
+ const dispose = () => {
134
+ if (processes.size === 0)
135
+ return Promise.resolve();
89
136
  for (const [taskId, child] of processes) {
137
+ cancelRequested.add(taskId);
138
+ logger.info(`Task ${taskId} cancelling (shutdown, SIGTERM)`);
90
139
  child.kill('SIGTERM');
91
- logger.info(`Task ${taskId} cancelled (shutdown)`);
140
+ armKillTimer(taskId, child, KILL_GRACE_MS);
92
141
  }
93
- processes.clear();
142
+ return new Promise((resolve) => {
143
+ const start = Date.now();
144
+ const check = () => {
145
+ if (processes.size === 0 || Date.now() - start >= DISPOSE_TIMEOUT_MS) {
146
+ // 仍未退出的升级为 SIGKILL;close 事件随后清理各集合
147
+ for (const [taskId, child] of processes) {
148
+ logger.warn(`Task ${taskId} force killed during shutdown`);
149
+ child.kill('SIGKILL');
150
+ }
151
+ resolve();
152
+ return;
153
+ }
154
+ setTimeout(check, DISPOSE_POLL_MS);
155
+ };
156
+ check();
157
+ });
94
158
  };
95
- return { execute, cancel, cancelAll };
159
+ return { execute, cancel, dispose };
96
160
  };
@@ -0,0 +1,13 @@
1
+ /**
2
+ * daemon 域统一出口。
3
+ *
4
+ * 说明:DaemonRunner.ts 是 daemon 子进程的入口脚本(模块顶层即执行 main(),
5
+ * 并校验 BRIER_DAEMON_MODE),由 DaemonManager 以文件路径 spawn,
6
+ * 不应被 import,故不在此导出。本目录其余模块均无副作用,可安全经此导入。
7
+ */
8
+ /** daemon 进程生命周期管理(前台 CLI 使用) */
9
+ export { createDaemonManager } from './DaemonManager.js';
10
+ /** 任务执行器(daemon 子进程内 spawn runtime) */
11
+ export { createTaskExecutor, } from './TaskExecutor.js';
12
+ /** daemon 运行状态读取(status/restart 展示与回退) */
13
+ export { readDaemonState } from './state.js';
@@ -0,0 +1,100 @@
1
+ import { execSync } from 'node:child_process';
2
+ import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
3
+ import { BRIER_DIR, DAEMON_STATE_FILE } from '../config/index.js';
4
+ /**
5
+ * daemon 运行状态契约(~/.brier/daemon.json,JSON)。
6
+ *
7
+ * 前台 CLI 与 daemon 子进程之间唯一的持久契约:Manager 写初始记录并轮询 ready,
8
+ * Runner 进入运行后置 ready/写 bootError、随隧道状态更新 tunnelState。
9
+ * 本模块同时提供进程存活与身份校验,避免 PID 被系统复用后误判/误杀。
10
+ */
11
+ const WAIT_POLL_MS = 200;
12
+ /** 仅判断 pid 是否存活(kill 0)。 */
13
+ export const isProcessAlive = (pid) => {
14
+ try {
15
+ process.kill(pid, 0);
16
+ return true;
17
+ }
18
+ catch (err) {
19
+ return err instanceof Error && 'code' in err && err.code === 'EPERM';
20
+ }
21
+ };
22
+ /** 读取进程命令行;ps 不可用或进程不存在时返回 null。 */
23
+ const readProcessCommand = (pid) => {
24
+ try {
25
+ const out = execSync(`ps -p ${pid} -o command=`, { stdio: ['ignore', 'pipe', 'ignore'] });
26
+ return out.toString().trim();
27
+ }
28
+ catch {
29
+ return null;
30
+ }
31
+ };
32
+ /**
33
+ * 校验状态记录对应的 daemon 是否存活:
34
+ * pid 存活 + 命令行含 DaemonRunner(防止 PID 复用后指向无关进程)。
35
+ * ps 不可用时退化为仅存活判断,避免跨平台误伤。
36
+ */
37
+ export const isDaemonRunning = (state) => {
38
+ if (!isProcessAlive(state.pid))
39
+ return false;
40
+ const command = readProcessCommand(state.pid);
41
+ return command === null || command.includes('DaemonRunner');
42
+ };
43
+ /** 读取运行状态;文件缺失或字段不全(含旧版格式)时返回 null。 */
44
+ export const readDaemonState = () => {
45
+ try {
46
+ const data = JSON.parse(readFileSync(DAEMON_STATE_FILE, 'utf-8'));
47
+ if (typeof data.pid !== 'number' ||
48
+ typeof data.startTime !== 'number' ||
49
+ typeof data.serverUrl !== 'string' ||
50
+ typeof data.ready !== 'boolean') {
51
+ return null;
52
+ }
53
+ return data;
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ };
59
+ /** 写入完整运行状态(Manager 启动时调用)。 */
60
+ export const writeDaemonState = (state) => {
61
+ mkdirSync(BRIER_DIR, { recursive: true });
62
+ writeFileSync(DAEMON_STATE_FILE, JSON.stringify(state, null, 2));
63
+ };
64
+ /** 合并更新状态(Runner 侧置 ready/bootError/tunnelState,保留其余字段)。 */
65
+ export const updateDaemonState = (patch) => {
66
+ const current = readDaemonState() ?? {
67
+ pid: process.pid,
68
+ startTime: Date.now(),
69
+ serverUrl: '',
70
+ ready: false,
71
+ };
72
+ writeDaemonState({ ...current, ...patch });
73
+ };
74
+ /** 清理运行状态文件(不存在时静默)。 */
75
+ export const removeDaemonState = () => {
76
+ try {
77
+ unlinkSync(DAEMON_STATE_FILE);
78
+ }
79
+ catch {
80
+ /* 文件不存在则忽略 */
81
+ }
82
+ };
83
+ /** 轮询等待 pid 退出;超时返回 false。 */
84
+ export const waitForProcessExit = (pid, timeoutMs = 5_000) => {
85
+ return new Promise((resolve) => {
86
+ const start = Date.now();
87
+ const check = () => {
88
+ if (!isProcessAlive(pid)) {
89
+ resolve(true);
90
+ return;
91
+ }
92
+ if (Date.now() - start >= timeoutMs) {
93
+ resolve(false);
94
+ return;
95
+ }
96
+ setTimeout(check, WAIT_POLL_MS);
97
+ };
98
+ check();
99
+ });
100
+ };
@@ -0,0 +1,5 @@
1
+ /**
2
+ * daemon 的装配配置、生命周期状态与进程间通信文件。
3
+ * 本文件三个类型均为“daemon 进程自身/前台 CLI 管理 daemon”使用,不参与隧道线协议。
4
+ */
5
+ export {};
@@ -0,0 +1,12 @@
1
+ /**
2
+ * 类型契约统一出口。
3
+ *
4
+ * 目录按功能域拆分:
5
+ * - tunnel.ts:隧道域(线协议 StreamType / ClientMessage / ServerMessage + 连接状态机 TunnelState)
6
+ * - task.ts: 本地任务执行上下文(消息 → 子进程执行)
7
+ * - daemon.ts:daemon 装配配置 / 生命周期状态 / PID 文件
8
+ *
9
+ * 约定:业务模块一律从本文件('../definitions/index.js')导入,不要跨层直接引用子文件,
10
+ * 以便后续调整目录结构时只需改这里,不动各调用方。
11
+ */
12
+ export {};
@@ -0,0 +1,9 @@
1
+ /**
2
+ * 隧道域类型(daemon ⇄ Brier 服务端 的连接与消息)。
3
+ *
4
+ * 聚合三组归属不同的类型:
5
+ * 1. StreamType / ClientMessage / ServerMessage:线协议(WebSocket JSON 文本帧),
6
+ * 与服务端 Rust `brier_type::tunnel::*` 镜像,任何命名/字段改动两端必须同步;
7
+ * 2. TunnelState:daemon 进程内的连接状态机(服务端无对应,不上报)。
8
+ */
9
+ export {};
package/dist/index.js CHANGED
@@ -1,61 +1,42 @@
1
1
  #!/usr/bin/env node
2
2
  import { Command } from 'commander';
3
+ import { readCliVersion } from './config/index.js';
3
4
  import { startCommand } from './commands/start.js';
4
5
  import { stopCommand } from './commands/stop.js';
5
6
  import { restartCommand } from './commands/restart.js';
6
7
  import { statusCommand } from './commands/status.js';
8
+ import { logCommand } from './commands/log.js';
9
+ import { runCommand } from './commands/run.js';
7
10
  const program = new Command();
8
11
  program
9
12
  .name('brier')
10
13
  .description('Brier CLI - Connect to Brier platform via encrypted tunnel')
11
- .version('0.0.1');
14
+ .version(readCliVersion());
12
15
  const daemon = program.command('daemon').description('Manage the brier background service');
13
16
  daemon
14
17
  .command('start')
15
18
  .description('Start the background service')
16
19
  .requiredOption('--server-url <url>', 'Brier server URL')
17
20
  .option('--token <token>', 'BRIER_TOKEN (or set BRIER_TOKEN env var)')
18
- .action(async (opts) => {
19
- try {
20
- await startCommand(opts);
21
- }
22
- catch (err) {
23
- console.error('Error:', err instanceof Error ? err.message : String(err));
24
- process.exit(1);
25
- }
26
- });
21
+ .action((opts) => runCommand(() => startCommand(opts)));
27
22
  daemon
28
23
  .command('stop')
29
24
  .description('Stop the background service')
30
- .action(async () => {
31
- try {
32
- await stopCommand();
33
- }
34
- catch (err) {
35
- console.error('Error:', err instanceof Error ? err.message : String(err));
36
- process.exit(1);
37
- }
38
- });
25
+ .action(() => runCommand(stopCommand));
39
26
  daemon
40
27
  .command('restart')
41
28
  .description('Restart the background service')
42
29
  .option('--server-url <url>', 'Brier server URL (defaults to previous config)')
43
30
  .option('--token <token>', 'BRIER_TOKEN (or set BRIER_TOKEN env var)')
44
- .action(async (opts) => {
45
- try {
46
- await restartCommand(opts);
47
- }
48
- catch (err) {
49
- console.error('Error:', err instanceof Error ? err.message : String(err));
50
- process.exit(1);
51
- }
52
- });
31
+ .action((opts) => runCommand(() => restartCommand(opts)));
53
32
  daemon
54
33
  .command('status')
55
34
  .description('Check the background service status')
56
- .action(() => {
57
- statusCommand();
58
- });
35
+ .action(() => runCommand(statusCommand));
36
+ daemon
37
+ .command('log')
38
+ .description('Show the latest daemon log lines')
39
+ .action(() => runCommand(logCommand));
59
40
  program.parseAsync(process.argv).catch((err) => {
60
41
  console.error('Fatal:', err instanceof Error ? err.message : String(err));
61
42
  process.exit(1);
@@ -0,0 +1,21 @@
1
+ export const createBackoff = (options) => {
2
+ let attempts = 0;
3
+ const useJitter = options.jitter ?? true;
4
+ return {
5
+ next() {
6
+ if (attempts >= options.maxAttempts) {
7
+ return -1;
8
+ }
9
+ attempts += 1;
10
+ const delay = Math.min(options.baseMs * Math.pow(2, attempts - 1), options.maxMs);
11
+ // full jitter:与指数退避正交,仅打散“同一时刻重连”的相位
12
+ return useJitter ? Math.floor(Math.random() * (delay + 1)) : delay;
13
+ },
14
+ get attempts() {
15
+ return attempts;
16
+ },
17
+ reset() {
18
+ attempts = 0;
19
+ },
20
+ };
21
+ };
@@ -0,0 +1,35 @@
1
+ import { logger } from '../core/index.js';
2
+ /** 解析并分发一条服务端消息;JSON 解析失败或未知 type 记日志后静默返回。 */
3
+ export const dispatchServerMessage = (raw, handlers) => {
4
+ let message;
5
+ try {
6
+ message = JSON.parse(raw);
7
+ }
8
+ catch (err) {
9
+ logger.error('Failed to parse server message:', err);
10
+ return;
11
+ }
12
+ switch (message.type) {
13
+ case 'auth-ok':
14
+ handlers.onAuthOk(message.computerId);
15
+ break;
16
+ case 'auth-failed':
17
+ handlers.onAuthFailed(message.reason);
18
+ break;
19
+ case 'heartbeat-ack':
20
+ handlers.onHeartbeatAck(message.timestamp);
21
+ break;
22
+ case 'task-start':
23
+ handlers.onTaskStart(message);
24
+ break;
25
+ case 'task-cancel':
26
+ handlers.onTaskCancel(message.taskId);
27
+ break;
28
+ case 'query-runtimes':
29
+ handlers.onQueryRuntimes();
30
+ break;
31
+ default:
32
+ // 协议演进中可能出现未知消息,忽略即可(记录以利于排查)
33
+ logger.warn('Unknown server message type:', message.type);
34
+ }
35
+ };
@@ -0,0 +1,55 @@
1
+ /**
2
+ * 应用层心跳策略。
3
+ *
4
+ * 只负责节奏:到点调用 onBeat() 发送心跳;onBeat 返回 true(已发出)时启动
5
+ * ack 超时计时,收到 ack 由调用方调 ack() 解除;超时触发 onTimeout()。
6
+ * 本模块“只上报不决策”——超时后如何处理(如关闭连接)由上层骨架决定。
7
+ *
8
+ * 注意:每次触发心跳前先清理上一次的 ack 超时计时器,避免重复计时器叠加。
9
+ */
10
+ export const createHeartbeat = (options) => {
11
+ let intervalTimer = null;
12
+ let timeoutTimer = null;
13
+ const clearTimeoutTimer = () => {
14
+ if (timeoutTimer) {
15
+ clearTimeout(timeoutTimer);
16
+ timeoutTimer = null;
17
+ }
18
+ };
19
+ const clearAllTimers = () => {
20
+ if (intervalTimer) {
21
+ clearInterval(intervalTimer);
22
+ intervalTimer = null;
23
+ }
24
+ clearTimeoutTimer();
25
+ };
26
+ const armTimeout = () => {
27
+ clearTimeoutTimer();
28
+ timeoutTimer = setTimeout(() => {
29
+ timeoutTimer = null;
30
+ options.onTimeout();
31
+ }, options.timeoutMs);
32
+ };
33
+ const beat = () => {
34
+ try {
35
+ if (options.onBeat()) {
36
+ armTimeout();
37
+ }
38
+ }
39
+ catch (error) {
40
+ options.onBeatError?.(error);
41
+ }
42
+ };
43
+ return {
44
+ start() {
45
+ clearAllTimers();
46
+ intervalTimer = setInterval(beat, options.intervalMs);
47
+ },
48
+ stop() {
49
+ clearAllTimers();
50
+ },
51
+ ack() {
52
+ clearTimeoutTimer();
53
+ },
54
+ };
55
+ };
@@ -0,0 +1,220 @@
1
+ import { toWsUrl } from './url.js';
2
+ import { logger } from '../core/index.js';
3
+ import { createBackoff } from './backoff.js';
4
+ import { dispatchServerMessage } from './dispatcher.js';
5
+ import { createHeartbeat } from './heartbeat.js';
6
+ import { createWebSocketTransport } from './transport.js';
7
+ const HEARTBEAT_INTERVAL_MS = 30_000;
8
+ const HEARTBEAT_TIMEOUT_MS = 10_000;
9
+ /** auth 消息发出后等待 auth-ok/auth-failed 的最长时间,超时视为握手失败并重连 */
10
+ const AUTH_HANDSHAKE_TIMEOUT_MS = 10_000;
11
+ const BASE_RECONNECT_DELAY_MS = 1_000;
12
+ const MAX_RECONNECT_DELAY_MS = 30_000;
13
+ const MAX_RECONNECT_ATTEMPTS = 50;
14
+ /**
15
+ * 隧道骨架:连接生命周期编排与状态仲裁的唯一入口。
16
+ *
17
+ * 本文件只做“编排”,不接触 ws 细节、不算退避、不解析消息:
18
+ * - transport:负责底层连接与收发帧
19
+ * - heartbeat:负责心跳节奏(只上报不决策)
20
+ * - backoff:负责重连延迟计算(纯逻辑)
21
+ * - dispatcher:负责服务端消息解析与分发
22
+ *
23
+ * 关键时序不变式(重构后保持与原实现一致):
24
+ * 1. 心跳仅在 auth-ok 后启动;
25
+ * 2. close 后仅当 running 才退避重连,stop() 后绝不再连;
26
+ * 3. 仅 auth-ok 归零退避计数;
27
+ * 4. stop 时先优雅 close,3s 兜底 terminate。
28
+ */
29
+ export const createTunnelClient = (config, messageHandlers) => {
30
+ const transport = createWebSocketTransport();
31
+ const backoff = createBackoff({
32
+ baseMs: BASE_RECONNECT_DELAY_MS,
33
+ maxMs: MAX_RECONNECT_DELAY_MS,
34
+ maxAttempts: MAX_RECONNECT_ATTEMPTS,
35
+ });
36
+ let state = 'disconnected';
37
+ let running = false;
38
+ let reconnectTimer = null;
39
+ /** auth 握手超时计时器(open 后启动,auth-ok/auth-failed/close 时清除) */
40
+ let handshakeTimer = null;
41
+ /**
42
+ * 本次运行是否以“错误”终止(auth 失败 / 重连耗尽)。
43
+ * 区分于 stop() 的正常停止:保证随后的 close 事件不把 error 覆盖成 disconnected。
44
+ */
45
+ let exitAsError = false;
46
+ const listeners = new Set();
47
+ const notifyStateChange = (newState) => {
48
+ state = newState;
49
+ for (const callback of listeners) {
50
+ callback(newState);
51
+ }
52
+ };
53
+ const onStateChange = (callback) => {
54
+ listeners.add(callback);
55
+ return () => {
56
+ listeners.delete(callback);
57
+ };
58
+ };
59
+ const getState = () => state;
60
+ const send = (message) => transport.send(JSON.stringify(message));
61
+ const sendHeartbeat = () => send({ type: 'heartbeat', timestamp: Date.now() });
62
+ const clearHandshakeTimer = () => {
63
+ if (handshakeTimer) {
64
+ clearTimeout(handshakeTimer);
65
+ handshakeTimer = null;
66
+ }
67
+ };
68
+ /**
69
+ * 启动握手超时:若服务端不回 auth-ok/auth-failed,10s 后主动断开走重连,
70
+ * 避免永久停留在 connecting(此时心跳尚未启动,无其他自愈路径)。
71
+ */
72
+ const armHandshakeTimeout = () => {
73
+ clearHandshakeTimer();
74
+ handshakeTimer = setTimeout(() => {
75
+ logger.warn('Auth handshake timed out, reconnecting');
76
+ transport.close(4000, 'auth timeout');
77
+ }, AUTH_HANDSHAKE_TIMEOUT_MS);
78
+ };
79
+ const heartbeat = createHeartbeat({
80
+ intervalMs: HEARTBEAT_INTERVAL_MS,
81
+ timeoutMs: HEARTBEAT_TIMEOUT_MS,
82
+ onBeat: sendHeartbeat,
83
+ onTimeout: () => {
84
+ logger.warn('Heartbeat timeout, forcing reconnect');
85
+ transport.close(4000, 'heartbeat timeout');
86
+ },
87
+ onBeatError: (error) => {
88
+ logger.error('Heartbeat send failed:', error);
89
+ },
90
+ });
91
+ const connect = () => {
92
+ logger.info('Connecting to', toWsUrl(config.serverUrl));
93
+ notifyStateChange('connecting');
94
+ transport.connect(toWsUrl(config.serverUrl), {
95
+ Authorization: `Bearer ${config.token}`,
96
+ 'X-Brier-Hostname': config.hostname,
97
+ 'X-Brier-OS': config.os,
98
+ });
99
+ };
100
+ const scheduleReconnect = () => {
101
+ if (!running)
102
+ return;
103
+ const delay = backoff.next();
104
+ if (delay < 0) {
105
+ logger.error('Max reconnect attempts reached, stopping');
106
+ exitAsError = true;
107
+ running = false;
108
+ notifyStateChange('error');
109
+ return;
110
+ }
111
+ logger.info(`Reconnecting in ${(delay / 1000).toFixed(0)}s (attempt ${backoff.attempts}/${MAX_RECONNECT_ATTEMPTS})`);
112
+ notifyStateChange('reconnecting');
113
+ reconnectTimer = setTimeout(() => {
114
+ if (running) {
115
+ connect();
116
+ }
117
+ }, delay);
118
+ };
119
+ const dispatchHandlers = {
120
+ onAuthOk: (computerId) => {
121
+ clearHandshakeTimer();
122
+ logger.info('Tunnel authenticated, computerId:', computerId);
123
+ backoff.reset();
124
+ notifyStateChange('connected');
125
+ heartbeat.start();
126
+ },
127
+ onAuthFailed: (reason) => {
128
+ clearHandshakeTimer();
129
+ logger.error('Authentication failed:', reason);
130
+ exitAsError = true;
131
+ running = false;
132
+ notifyStateChange('error');
133
+ },
134
+ onHeartbeatAck: () => {
135
+ heartbeat.ack();
136
+ },
137
+ onTaskStart: (message) => {
138
+ logger.info('Task start:', message.taskId, message.command);
139
+ messageHandlers.onTaskStart(message);
140
+ },
141
+ onTaskCancel: (taskId) => {
142
+ logger.info('Task cancel:', taskId);
143
+ messageHandlers.onTaskCancel(taskId);
144
+ },
145
+ onQueryRuntimes: () => {
146
+ send({ type: 'runtime-info', runtimes: config.runtimes });
147
+ },
148
+ };
149
+ // 传输事件 → 骨架仲裁(订阅一次,跨多次重连保持有效)
150
+ transport.on('open', () => {
151
+ logger.info('WebSocket connected, authenticating...');
152
+ send({
153
+ type: 'auth',
154
+ token: config.token,
155
+ hostname: config.hostname,
156
+ os: config.os,
157
+ runtimes: config.runtimes,
158
+ version: config.version,
159
+ });
160
+ armHandshakeTimeout();
161
+ });
162
+ transport.on('message', (data) => dispatchServerMessage(data, dispatchHandlers));
163
+ transport.on('close', (code, reason) => {
164
+ const reasonStr = reason || `code ${code}`;
165
+ logger.warn(`WebSocket closed: ${reasonStr}`);
166
+ clearHandshakeTimer();
167
+ heartbeat.stop();
168
+ if (running) {
169
+ scheduleReconnect();
170
+ }
171
+ else {
172
+ // 失败停机(auth 失败/重连耗尽)保持 error,正常 stop 才是 disconnected
173
+ notifyStateChange(exitAsError ? 'error' : 'disconnected');
174
+ }
175
+ });
176
+ transport.on('error', (err) => {
177
+ logger.error('WebSocket error:', err);
178
+ });
179
+ const start = () => {
180
+ if (running) {
181
+ logger.warn('Tunnel is already running');
182
+ return;
183
+ }
184
+ running = true;
185
+ exitAsError = false;
186
+ backoff.reset();
187
+ connect();
188
+ };
189
+ const stop = async () => {
190
+ running = false;
191
+ heartbeat.stop();
192
+ clearHandshakeTimer();
193
+ // 取消运行中任务由宿主(DaemonRunner)在自身 shutdown 里处理,隧道只负责关闭连接
194
+ if (reconnectTimer) {
195
+ clearTimeout(reconnectTimer);
196
+ reconnectTimer = null;
197
+ }
198
+ // 连接已完全关闭(从未连接/已断开/已 teardown):close 事件不会再触发,
199
+ // 直接返回,避免空等 waitClosed 的 3s 兜底超时。
200
+ if (transport.isClosed()) {
201
+ notifyStateChange('disconnected');
202
+ logger.info('Tunnel stopped');
203
+ return;
204
+ }
205
+ transport.close(1000, 'client shutdown');
206
+ await transport.waitClosed(3000);
207
+ if (transport.isOpen() || transport.isConnecting()) {
208
+ transport.terminate();
209
+ }
210
+ notifyStateChange('disconnected');
211
+ logger.info('Tunnel stopped');
212
+ };
213
+ return {
214
+ start,
215
+ stop,
216
+ send,
217
+ getState,
218
+ onStateChange,
219
+ };
220
+ };