@brierb/brier-cli 0.0.6 → 0.0.8

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 (65) 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/core/runtimes.js +74 -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 +79 -10
  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/core/runtimes.d.ts +29 -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 -81
  57. package/dist/runtimes.js +0 -29
  58. package/dist/tunnel/TunnelClient.js +0 -218
  59. package/types/config.d.ts +0 -9
  60. package/types/runtimes.d.ts +0 -14
  61. package/types/tunnel/TunnelClient.d.ts +0 -10
  62. package/types/types.d.ts +0 -78
  63. /package/dist/{logger.js → core/logger.js} +0 -0
  64. /package/dist/{types.js → definitions/task.js} +0 -0
  65. /package/types/{logger.d.ts → core/logger.d.ts} +0 -0
@@ -0,0 +1,77 @@
1
+ const DEFAULT_MAX_BATCH_BYTES = 16 * 1024;
2
+ const DEFAULT_FLUSH_INTERVAL_MS = 50;
3
+ const keyOf = (taskId, stream) => `${taskId}\u0000${stream}`;
4
+ export const createOutputBatcher = (options) => {
5
+ const maxBatchBytes = options.maxBatchBytes ?? DEFAULT_MAX_BATCH_BYTES;
6
+ const flushIntervalMs = options.flushIntervalMs ?? DEFAULT_FLUSH_INTERVAL_MS;
7
+ const pending = new Map();
8
+ let timer = null;
9
+ const flushEntry = (key, entry) => {
10
+ pending.delete(key);
11
+ options.onFlush([
12
+ {
13
+ taskId: entry.taskId,
14
+ stream: entry.stream,
15
+ data: entry.parts.join(''),
16
+ },
17
+ ]);
18
+ };
19
+ const ensureTimer = () => {
20
+ if (timer)
21
+ return;
22
+ timer = setInterval(() => {
23
+ if (pending.size === 0)
24
+ return;
25
+ const entries = [...pending.entries()];
26
+ for (const [key, entry] of entries) {
27
+ if (pending.has(key)) {
28
+ try {
29
+ flushEntry(key, entry);
30
+ }
31
+ catch (err) {
32
+ pending.delete(key);
33
+ // flush 回调抛错不应杀死进程(setInterval 回调中的异常成为 uncaughtException)
34
+ // 丢弃该缓冲项,其余继续
35
+ }
36
+ }
37
+ }
38
+ }, flushIntervalMs);
39
+ };
40
+ return {
41
+ push(taskId, stream, data) {
42
+ if (data.length === 0)
43
+ return;
44
+ ensureTimer();
45
+ const key = keyOf(taskId, stream);
46
+ let entry = pending.get(key);
47
+ if (!entry) {
48
+ entry = { taskId, stream, parts: [], bytes: 0 };
49
+ pending.set(key, entry);
50
+ }
51
+ entry.parts.push(data);
52
+ entry.bytes += data.length;
53
+ if (entry.bytes >= maxBatchBytes) {
54
+ flushEntry(key, entry);
55
+ }
56
+ },
57
+ flushTask(taskId) {
58
+ const entries = [...pending.entries()].filter(([, e]) => e.taskId === taskId);
59
+ for (const [key, entry] of entries) {
60
+ flushEntry(key, entry);
61
+ }
62
+ },
63
+ flushAll() {
64
+ const entries = [...pending.entries()];
65
+ for (const [key, entry] of entries) {
66
+ flushEntry(key, entry);
67
+ }
68
+ },
69
+ dispose() {
70
+ if (timer) {
71
+ clearInterval(timer);
72
+ timer = null;
73
+ }
74
+ pending.clear();
75
+ },
76
+ };
77
+ };
@@ -1,14 +1,46 @@
1
1
  import { spawn } from 'node:child_process';
2
- import { logger } from '../logger.js';
3
- import { RUNTIME_COMMANDS, 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
+ };
34
+ /**
35
+ * 解析要执行的命令:
36
+ * - 显式 command:直接用(调用方负责其可执行性)
37
+ * - runtime 模式:解析为**绝对路径**(PATH 或官方安装目录),
38
+ * 避免 daemon 进程 PATH 不含 runtime 目录时 spawn ENOENT
39
+ */
7
40
  const resolveCommand = (task) => {
8
41
  if (task.command)
9
42
  return task.command;
10
- const mapped = RUNTIME_COMMANDS[task.runtime];
11
- return mapped ?? null;
43
+ return resolveRuntimeExecutable(task.runtime);
12
44
  };
13
45
  /**
14
46
  * 拼执行参数:
@@ -59,12 +91,26 @@ export const createTaskExecutor = (callbacks) => {
59
91
  callbacks.onOutput(task.taskId, 'stderr', data.toString());
60
92
  });
61
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);
62
99
  processes.delete(task.taskId);
63
100
  logger.error(`Task ${task.taskId} process error:`, err.message);
64
101
  callbacks.onError(task.taskId, err.message);
65
102
  });
66
103
  child.on('close', (code) => {
104
+ if (finished.has(task.taskId))
105
+ return;
106
+ finished.add(task.taskId);
107
+ clearKillTimer(task.taskId);
67
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
+ }
68
114
  const exitCode = code ?? 0;
69
115
  logger.info(`Task ${task.taskId} completed with exit code ${exitCode}`);
70
116
  callbacks.onComplete(task.taskId, exitCode);
@@ -76,16 +122,39 @@ export const createTaskExecutor = (callbacks) => {
76
122
  logger.warn(`Task ${taskId} not found, cannot cancel`);
77
123
  return;
78
124
  }
125
+ if (cancelRequested.has(taskId)) {
126
+ return;
127
+ }
128
+ cancelRequested.add(taskId);
129
+ logger.info(`Task ${taskId} cancelling (SIGTERM)`);
79
130
  child.kill('SIGTERM');
80
- processes.delete(taskId);
81
- logger.info(`Task ${taskId} cancelled`);
131
+ armKillTimer(taskId, child, KILL_GRACE_MS);
82
132
  };
83
- const cancelAll = () => {
133
+ const dispose = () => {
134
+ if (processes.size === 0)
135
+ return Promise.resolve();
84
136
  for (const [taskId, child] of processes) {
137
+ cancelRequested.add(taskId);
138
+ logger.info(`Task ${taskId} cancelling (shutdown, SIGTERM)`);
85
139
  child.kill('SIGTERM');
86
- logger.info(`Task ${taskId} cancelled (shutdown)`);
140
+ armKillTimer(taskId, child, KILL_GRACE_MS);
87
141
  }
88
- 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
+ });
89
158
  };
90
- return { execute, cancel, cancelAll };
159
+ return { execute, cancel, dispose };
91
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
+ };