@the-seeker/server-agent 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/README.md ADDED
@@ -0,0 +1,106 @@
1
+ # @the-seeker/server-agent
2
+
3
+ Node.js 20 이상에서 실행하는 읽기 전용 서버 모니터링 수집기입니다.
4
+ 수집기, 게이트웨이 WebSocket 클라이언트(`run`), systemd/launchd 설치 명령
5
+ (`install`·`uninstall`·`status`)을 포함합니다.
6
+
7
+ ## 설치
8
+
9
+ Ubuntu(systemd) 및 macOS(launchd) 공통 설치 스크립트:
10
+
11
+ ```bash
12
+ TOKEN=sa_... ENDPOINT=wss://ingest.theseeker.io/agent/ws bash scripts/install.sh
13
+ ```
14
+
15
+ 수동 설치 명령은 Ubuntu와 macOS에서 다음과 같습니다. Linux에서는 두 단계 모두 root 권한이 필요하며,
16
+ 에이전트 프로세스 자체는 sudo 를 사용하지 않습니다(생성되는 `ExecStart=` 에도 없음).
17
+
18
+ Ubuntu:
19
+
20
+ ```bash
21
+ sudo npm i -g @the-seeker/server-agent@latest
22
+ sudo theseeker-agent install --token "$TOKEN" --endpoint "$ENDPOINT" --user "$USER"
23
+ ```
24
+
25
+ macOS:
26
+
27
+ ```bash
28
+ npm i -g @the-seeker/server-agent@latest
29
+ theseeker-agent install --token "$TOKEN" --endpoint "$ENDPOINT" --user "$USER"
30
+ ```
31
+
32
+ `install` 옵션: `--token`(필수), `--endpoint`(기본 `wss://ingest.theseeker.io/agent/ws`),
33
+ `--user`(기본 `$SUDO_USER` → `$USER`), `--pm2-home`(기본 `<user home>/.pm2`),
34
+ `--add-groups`(systemd 유닛에 `SupplementaryGroups=adm systemd-journal` 추가).
35
+ `--add-groups` 를 생략하면 `usermod -aG adm,systemd-journal <user>` 안내가 출력됩니다.
36
+
37
+ 설정 파일은 `0600`으로 기록되고 root 실행 시 서비스 사용자에게 `chown` 됩니다
38
+ (디렉터리는 `0750`). Linux 에서 root 가 아니면 아무것도 쓰지 않고 수동 명령을 출력한 뒤
39
+ 종료 코드 `2`로 끝납니다. macOS 는 `~/Library/LaunchAgents/io.theseeker.agent.plist`를
40
+ 쓰고 `launchctl bootstrap gui/$(id -u)`(실패 시 `launchctl load -w`)로 적재하며,
41
+ LaunchAgent 는 GUI 로그인 세션에 묶입니다(부팅 시 자동 실행 아님).
42
+
43
+ `install`·`uninstall` 을 반복 실행해도 같은 유닛/plist 를 다시 쓸 뿐 중복이 생기지 않습니다.
44
+ `THESEEKER_AGENT_SKIP_SERVICE_MANAGER=1` 은 QA 전용으로 `systemctl`/`launchctl` 호출만
45
+ 건너뜁니다(실제 로그인 세션에 적재하지 않고 파일 생성만 검증할 때 사용).
46
+
47
+ 소스 체크아웃에서 빌드하려면 이 패키지 디렉터리에서:
48
+
49
+ ```bash
50
+ npm install --include=dev
51
+ npm run build
52
+ ```
53
+
54
+ ## 실행
55
+
56
+ 현재 수집기 확인:
57
+
58
+ ```bash
59
+ node --input-type=module -e 'import { collectHost } from "./dist/collectors/host.js"; console.log(await collectHost())'
60
+ ```
61
+
62
+ `collectHost(previousSample?)`는 `{ host, collected_at, sample }`을 반환합니다.
63
+ 다음 호출에는 `sample`을 전달하며, 프레임에는 `host`와 `collected_at`만 넣습니다.
64
+ `sample.measuredAt`은 monotonic milliseconds이며 네트워크 속도는 bytes/second입니다.
65
+
66
+ `collectPm2(registry, { pm2Home?, signal? })`,
67
+ `collectSystemd(watchedServices, registry, { platform?, signal? })`,
68
+ `collectNginx(nginxEnabled, registry, signal?)`는 `capability`와 프레임용 목록을 반환합니다.
69
+ PM2 목록은 `processes`, systemd는 `services`, 공통 로그 메타데이터는 `sources`입니다.
70
+ `collectCapabilities({ host, pm2, systemd, nginx })`로 상태를 합칩니다.
71
+
72
+ nginx 상대 로그 경로는 필요할 때만 `nginx -V`의 `--prefix`를 조회해 절대 경로로
73
+ 변환합니다(nginx 기본값 `/usr/local/nginx`). 요청별 변수가 포함된 로그 경로는
74
+ 정적인 파일로 확정할 수 없어 광고하지 않습니다.
75
+
76
+ PM2 실행 위치는 `PM2_BIN`, 데이터 위치는 `pm2Home` 또는 `PM2_HOME`으로 설정합니다.
77
+ PM2 환경·실행 인수·실행 경로는 반환하지 않습니다. 로그 경로와 journal unit은
78
+ `SourceRegistry` 내부에 보관하고 `tail(sourceId, lines, signal?)`로만 읽습니다.
79
+ `lines`는 1~500이며 프로토콜 요청은 100/200/500만 허용합니다.
80
+ 반환은 `{ source_id, lines, truncated, bytes }`, 실패는 `CollectionError.code`입니다.
81
+ 로그는 최대 256 KiB이며 경로를 벗어나는 symlink와 일반 파일이 아닌 대상은 거부합니다.
82
+
83
+ capability: `available`=조회 성공, `unsupported`=OS 미지원 또는 nginx 비활성,
84
+ `not_installed`=실행 파일 없음, `permission_denied`=권한 부족, `error`=기타 조회/파싱 실패.
85
+ 호스트 조회 실패는 호출자가 처리하고 host capability를 `error`로 지정합니다.
86
+
87
+ ## 상태 확인과 제거
88
+
89
+ Ubuntu:
90
+
91
+ ```bash
92
+ sudo theseeker-agent status --user "$USER" # 서비스 상태 + 마지막 로그 20줄
93
+ sudo theseeker-agent uninstall --user "$USER"
94
+ ```
95
+
96
+ macOS:
97
+
98
+ ```bash
99
+ theseeker-agent status --user "$USER" # 서비스 상태 + 마지막 로그 20줄
100
+ theseeker-agent uninstall --user "$USER"
101
+ ```
102
+
103
+ 패키지를 제거할 때는 `npm uninstall -g @the-seeker/server-agent`를 실행한다.
104
+
105
+ `uninstall` 은 서비스를 정지·비활성화하고 유닛/plist 와 설정 파일을 제거하며
106
+ 로그 디렉터리는 남깁니다.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export declare function main(argv: readonly string[]): Promise<number>;
package/dist/cli.js ADDED
@@ -0,0 +1,101 @@
1
+ #!/usr/bin/env node
2
+ import { realpathSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { createAgentClient } from "./client.js";
5
+ import { AGENT_VERSION, createLogger, loadConfig } from "./config.js";
6
+ import { createInstallEnvironment, SKIP_SERVICE_MANAGER_ENV } from "./install/detect.js";
7
+ import { INSTALL_USAGE, runInstall, runStatus, runUninstall } from "./install/index.js";
8
+ const SERVICE_COMMANDS = ["install", "uninstall", "status"];
9
+ const USAGE = [
10
+ "theseeker-agent <command>",
11
+ "",
12
+ "Commands:",
13
+ " run run the agent in the foreground",
14
+ " install install the systemd unit (linux) or LaunchAgent (macOS) and write the config",
15
+ " uninstall stop the service and remove the unit/plist and config (logs are kept)",
16
+ " status show the service state and the last log lines",
17
+ "",
18
+ INSTALL_USAGE,
19
+ "",
20
+ "Options:",
21
+ " --version print the agent version",
22
+ " --help print this message",
23
+ "",
24
+ "Environment:",
25
+ " THESEEKER_AGENT_ENDPOINT gateway websocket URL (overrides the config file)",
26
+ " THESEEKER_AGENT_TOKEN agent token (overrides the config file)",
27
+ ` ${SKIP_SERVICE_MANAGER_ENV} install/uninstall without calling systemctl/launchctl (QA only)`,
28
+ ].join("\n");
29
+ function write(line) {
30
+ process.stdout.write(`${line}\n`);
31
+ }
32
+ async function runForeground() {
33
+ const config = await loadConfig();
34
+ const log = createLogger(config.logLevel);
35
+ const client = createAgentClient({ config, log });
36
+ let closing = false;
37
+ const shutdown = () => {
38
+ if (closing)
39
+ return;
40
+ closing = true;
41
+ void client.stop().then(() => { process.exitCode = 0; });
42
+ };
43
+ process.on("SIGINT", shutdown);
44
+ process.on("SIGTERM", shutdown);
45
+ log("info", "starting", { version: AGENT_VERSION });
46
+ client.start();
47
+ return 0;
48
+ }
49
+ export async function main(argv) {
50
+ const args = argv.filter((argument) => argument.length > 0);
51
+ const command = args[0];
52
+ if (args.includes("--version") || args.includes("-v")) {
53
+ write(AGENT_VERSION);
54
+ return 0;
55
+ }
56
+ if (!command || args.includes("--help") || args.includes("-h")) {
57
+ write(USAGE);
58
+ return command ? 0 : 1;
59
+ }
60
+ if (SERVICE_COMMANDS.some((name) => name === command)) {
61
+ const environment = createInstallEnvironment({ print: write });
62
+ const rest = args.slice(1);
63
+ try {
64
+ if (command === "install")
65
+ return await runInstall(rest, environment);
66
+ if (command === "uninstall")
67
+ return await runUninstall(rest, environment);
68
+ return await runStatus(rest, environment);
69
+ }
70
+ catch (error) {
71
+ write(error instanceof Error ? `error: ${error.message}` : `error: ${command} failed`);
72
+ return 1;
73
+ }
74
+ }
75
+ if (command !== "run") {
76
+ write(`unknown command: ${command}\n\n${USAGE}`);
77
+ return 1;
78
+ }
79
+ try {
80
+ return await runForeground();
81
+ }
82
+ catch (error) {
83
+ write(error instanceof Error ? `error: ${error.message}` : "error: agent failed to start");
84
+ return 1;
85
+ }
86
+ }
87
+ function invokedDirectly() {
88
+ const entry = process.argv[1];
89
+ if (!entry)
90
+ return false;
91
+ try {
92
+ return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
93
+ }
94
+ catch {
95
+ return false;
96
+ }
97
+ }
98
+ if (invokedDirectly()) {
99
+ void main(process.argv.slice(2)).then((code) => { if (code !== 0)
100
+ process.exitCode = code; });
101
+ }
@@ -0,0 +1,56 @@
1
+ import type { HostCollection, NetworkSample } from "./collectors/host.js";
2
+ import type { NginxCollection } from "./collectors/nginx.js";
3
+ import type { Pm2Collection } from "./collectors/pm2.js";
4
+ import { SourceRegistry } from "./collectors/sources.js";
5
+ import type { SystemdCollection } from "./collectors/systemd.js";
6
+ import type { AgentConfig, Logger } from "./config.js";
7
+ import type { Host } from "./protocol.js";
8
+ export declare const MIN_INTERVAL_SEC = 10;
9
+ export declare const MAX_INTERVAL_SEC = 60;
10
+ export declare const SAMPLE_INTERVAL_MS = 5000;
11
+ export declare const BACKOFF_BASE_MS = 1000;
12
+ export declare const BACKOFF_CAP_MS = 60000;
13
+ export declare const HANDSHAKE_TIMEOUT_MS = 10000;
14
+ export type AgentPlatform = "linux" | "darwin";
15
+ export interface AgentCollectors {
16
+ readonly host: (previous?: NetworkSample) => Promise<HostCollection>;
17
+ readonly pm2: (registry: SourceRegistry, options: {
18
+ readonly pm2Home?: string;
19
+ readonly signal?: AbortSignal;
20
+ }) => Promise<Pm2Collection>;
21
+ readonly systemd: (watched: readonly string[], registry: SourceRegistry, options: {
22
+ readonly platform?: string;
23
+ readonly signal?: AbortSignal;
24
+ }) => Promise<SystemdCollection>;
25
+ readonly nginx: (enabled: boolean, registry: SourceRegistry, signal?: AbortSignal) => Promise<NginxCollection>;
26
+ }
27
+ export interface AgentClientOptions {
28
+ readonly config: AgentConfig;
29
+ readonly agentVersion?: string | undefined;
30
+ readonly log?: Logger | undefined;
31
+ readonly registry?: SourceRegistry | undefined;
32
+ readonly collectors?: Partial<AgentCollectors> | undefined;
33
+ readonly platform?: AgentPlatform | undefined;
34
+ readonly sampleIntervalMs?: number | undefined;
35
+ /** Test seam: maps the negotiated `interval_sec` to a timer period. Defaults to seconds. */
36
+ readonly intervalToMs?: ((intervalSec: number) => number) | undefined;
37
+ readonly random?: (() => number) | undefined;
38
+ readonly handshakeTimeoutMs?: number | undefined;
39
+ }
40
+ export interface AgentClient {
41
+ start(): void;
42
+ stop(): Promise<void>;
43
+ readonly connected: boolean;
44
+ readonly seq: number;
45
+ readonly intervalSec: number;
46
+ readonly heartbeatIntervalMs: number;
47
+ }
48
+ export declare class AgentPlatformError extends Error {
49
+ constructor(platform: string);
50
+ }
51
+ export declare function clampIntervalSec(intervalSec: number): number;
52
+ /** AWS full jitter: `random(0, min(cap, base * 2^attempt))`. */
53
+ export declare function backoffDelayMs(attempt: number, random?: () => number): number;
54
+ /** Averages cpu/load/net across the samples taken since the previous heartbeat; mem/swap/disks keep the latest reading. */
55
+ export declare function averageHosts(samples: readonly Host[]): Host;
56
+ export declare function createAgentClient(options: AgentClientOptions): AgentClient;
package/dist/client.js ADDED
@@ -0,0 +1,365 @@
1
+ import { arch, cpus, hostname, platform as currentPlatform, release, uptime } from "node:os";
2
+ import { WebSocket } from "ws";
3
+ import { collectCapabilities } from "./collectors/capabilities.js";
4
+ import { collectHost } from "./collectors/host.js";
5
+ import { collectNginx } from "./collectors/nginx.js";
6
+ import { collectPm2 } from "./collectors/pm2.js";
7
+ import { SourceRegistry } from "./collectors/sources.js";
8
+ import { collectSystemd } from "./collectors/systemd.js";
9
+ import { AGENT_VERSION, createLogger } from "./config.js";
10
+ import { createRequestHandlers, errorResponse } from "./handlers.js";
11
+ import { HeartbeatFrameSchema, HelloFrameSchema, MAX_HEARTBEAT_BYTES, MAX_RESPONSE_BYTES, PROTOCOL_VERSION, ResponseFrameSchema, parseGatewayFrame, } from "./protocol.js";
12
+ export const MIN_INTERVAL_SEC = 10;
13
+ export const MAX_INTERVAL_SEC = 60;
14
+ export const SAMPLE_INTERVAL_MS = 5000;
15
+ export const BACKOFF_BASE_MS = 1000;
16
+ export const BACKOFF_CAP_MS = 60000;
17
+ export const HANDSHAKE_TIMEOUT_MS = 10000;
18
+ const MAX_BUFFERED_SAMPLES = 64;
19
+ const CLOSE_GRACE_MS = 2000;
20
+ export class AgentPlatformError extends Error {
21
+ constructor(platform) {
22
+ super(`unsupported platform: ${platform}`);
23
+ this.name = "AgentPlatformError";
24
+ }
25
+ }
26
+ export function clampIntervalSec(intervalSec) {
27
+ if (!Number.isFinite(intervalSec))
28
+ return MIN_INTERVAL_SEC;
29
+ return Math.min(MAX_INTERVAL_SEC, Math.max(MIN_INTERVAL_SEC, Math.floor(intervalSec)));
30
+ }
31
+ /** AWS full jitter: `random(0, min(cap, base * 2^attempt))`. */
32
+ export function backoffDelayMs(attempt, random = Math.random) {
33
+ const bound = Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * 2 ** Math.max(0, attempt));
34
+ return Math.floor(random() * bound);
35
+ }
36
+ function mean(values) {
37
+ if (!values.length)
38
+ return null;
39
+ return values.reduce((total, value) => total + value, 0) / values.length;
40
+ }
41
+ /** Averages cpu/load/net across the samples taken since the previous heartbeat; mem/swap/disks keep the latest reading. */
42
+ export function averageHosts(samples) {
43
+ const latest = samples[samples.length - 1];
44
+ if (!latest)
45
+ throw new Error("averageHosts requires at least one sample");
46
+ const cpu = mean(samples.map((sample) => sample.cpu_pct).filter((value) => value !== null));
47
+ const loads = samples.map((sample) => sample.load).filter((value) => value !== null);
48
+ const axis = (index) => mean(loads.map((load) => load[index])) ?? 0;
49
+ return {
50
+ ...latest,
51
+ cpu_pct: cpu === null ? null : Math.max(0, Math.min(100, cpu)),
52
+ load: loads.length ? [axis(0), axis(1), axis(2)] : null,
53
+ net: {
54
+ rx_bps: mean(samples.map((sample) => sample.net.rx_bps).filter((value) => value !== null)),
55
+ tx_bps: mean(samples.map((sample) => sample.net.tx_bps).filter((value) => value !== null)),
56
+ },
57
+ };
58
+ }
59
+ function unavailableHost() {
60
+ return {
61
+ cpu_pct: null, cpu_count: cpus().length, load: null, mem: null, swap: null,
62
+ disks: [], net: { rx_bps: null, tx_bps: null }, uptime_sec: uptime(),
63
+ };
64
+ }
65
+ function resolvePlatform(platform) {
66
+ if (platform !== "linux" && platform !== "darwin")
67
+ throw new AgentPlatformError(platform);
68
+ return platform;
69
+ }
70
+ export function createAgentClient(options) {
71
+ const log = options.log ?? createLogger(options.config.logLevel);
72
+ const agentVersion = options.agentVersion ?? AGENT_VERSION;
73
+ const platform = resolvePlatform(options.platform ?? currentPlatform());
74
+ const registry = options.registry ?? new SourceRegistry(options.config.pm2Home);
75
+ const collectors = {
76
+ host: options.collectors?.host ?? collectHost,
77
+ pm2: options.collectors?.pm2 ?? collectPm2,
78
+ systemd: options.collectors?.systemd ?? collectSystemd,
79
+ nginx: options.collectors?.nginx ?? collectNginx,
80
+ };
81
+ const sampleIntervalMs = options.sampleIntervalMs ?? SAMPLE_INTERVAL_MS;
82
+ const intervalToMs = options.intervalToMs ?? ((intervalSec) => intervalSec * 1000);
83
+ const random = options.random ?? Math.random;
84
+ let intervalSec = MIN_INTERVAL_SEC;
85
+ let watchedServices = [];
86
+ let nginxEnabled = true;
87
+ let nginxSources = [];
88
+ let nginxCapability = "unsupported";
89
+ let nginxDirty = true;
90
+ let hostCapability = "error";
91
+ let previousSample;
92
+ let samples = [];
93
+ let sampling = false;
94
+ let beating = false;
95
+ let socket = null;
96
+ let seq = 0;
97
+ let attempt = 0;
98
+ let stopped = true;
99
+ let heartbeatTimer = null;
100
+ let sampleTimer = null;
101
+ let reconnectTimer = null;
102
+ const handlers = createRequestHandlers({
103
+ registry, log, platform,
104
+ state: () => ({ nginxEnabled, ...(options.config.pm2Home ? { pm2Home: options.config.pm2Home } : {}) }),
105
+ onNginxSources: (sources) => { nginxSources = [...sources]; nginxCapability = "available"; nginxDirty = false; },
106
+ });
107
+ function pm2Options() {
108
+ return options.config.pm2Home ? { pm2Home: options.config.pm2Home } : {};
109
+ }
110
+ async function sample() {
111
+ if (sampling)
112
+ return;
113
+ sampling = true;
114
+ try {
115
+ const collection = await collectors.host(previousSample);
116
+ previousSample = collection.sample;
117
+ samples.push(collection.host);
118
+ if (samples.length > MAX_BUFFERED_SAMPLES)
119
+ samples = samples.slice(-MAX_BUFFERED_SAMPLES);
120
+ hostCapability = "available";
121
+ }
122
+ catch {
123
+ hostCapability = "error";
124
+ log("warn", "host_sample_failed");
125
+ }
126
+ finally {
127
+ sampling = false;
128
+ }
129
+ }
130
+ async function refreshNginx() {
131
+ const collection = await collectors.nginx(nginxEnabled, registry);
132
+ nginxSources = collection.sources;
133
+ nginxCapability = collection.capability;
134
+ nginxDirty = false;
135
+ }
136
+ function isOpen(candidate) {
137
+ return candidate !== null && candidate === socket && candidate.readyState === WebSocket.OPEN;
138
+ }
139
+ function send(candidate, text) {
140
+ if (!isOpen(candidate))
141
+ return;
142
+ candidate.send(text);
143
+ }
144
+ async function sendHello(target) {
145
+ const [pm2, systemd] = await Promise.all([
146
+ collectors.pm2(registry, pm2Options()),
147
+ collectors.systemd(watchedServices, registry, {}),
148
+ sample(),
149
+ ]);
150
+ await refreshNginx();
151
+ const capabilities = collectCapabilities({ host: hostCapability, pm2, systemd, nginx: { capability: nginxCapability } });
152
+ const frame = HelloFrameSchema.parse({
153
+ type: "hello", protocol_version: PROTOCOL_VERSION, agent_version: agentVersion, hostname: hostname(),
154
+ os: { platform, release: release(), arch: arch() }, cpu_count: cpus().length,
155
+ boot_time: new Date(Date.now() - uptime() * 1000).toISOString(),
156
+ capabilities, sources: [...pm2.sources, ...systemd.sources, ...nginxSources],
157
+ });
158
+ send(target, JSON.stringify(frame));
159
+ log("info", "hello_sent", { sources: frame.sources.length });
160
+ }
161
+ function serializeHeartbeat(frame) {
162
+ const full = JSON.stringify(HeartbeatFrameSchema.parse(frame));
163
+ if (Buffer.byteLength(full) <= MAX_HEARTBEAT_BYTES)
164
+ return full;
165
+ const trimmed = JSON.stringify(HeartbeatFrameSchema.parse({
166
+ type: frame.type, seq: frame.seq, collected_at: frame.collected_at, host: frame.host,
167
+ processes: frame.processes, services: frame.services, capabilities: frame.capabilities,
168
+ }));
169
+ if (Buffer.byteLength(trimmed) <= MAX_HEARTBEAT_BYTES)
170
+ return trimmed;
171
+ return null;
172
+ }
173
+ async function sendHeartbeat(target) {
174
+ if (beating)
175
+ return;
176
+ beating = true;
177
+ try {
178
+ if (!samples.length)
179
+ await sample();
180
+ const [pm2, systemd] = await Promise.all([
181
+ collectors.pm2(registry, pm2Options()),
182
+ collectors.systemd(watchedServices, registry, {}),
183
+ ]);
184
+ if (nginxDirty)
185
+ await refreshNginx();
186
+ const host = samples.length ? averageHosts(samples) : unavailableHost();
187
+ samples = [];
188
+ const next = seq + 1;
189
+ const frame = {
190
+ type: "heartbeat", seq: next, collected_at: new Date().toISOString(), host,
191
+ processes: pm2.processes, services: systemd.services,
192
+ capabilities: collectCapabilities({ host: hostCapability, pm2, systemd, nginx: { capability: nginxCapability } }),
193
+ sources: [...pm2.sources, ...systemd.sources, ...nginxSources],
194
+ };
195
+ if (!isOpen(target)) {
196
+ log("debug", "heartbeat_dropped");
197
+ return;
198
+ }
199
+ const text = serializeHeartbeat(frame);
200
+ if (!text) {
201
+ log("warn", "heartbeat_too_large");
202
+ return;
203
+ }
204
+ send(target, text);
205
+ seq = next;
206
+ log("debug", "heartbeat_sent", { seq, processes: frame.processes.length, services: frame.services.length });
207
+ }
208
+ catch {
209
+ log("warn", "heartbeat_failed");
210
+ }
211
+ finally {
212
+ beating = false;
213
+ }
214
+ }
215
+ function stopTimers() {
216
+ if (heartbeatTimer)
217
+ clearInterval(heartbeatTimer);
218
+ if (sampleTimer)
219
+ clearInterval(sampleTimer);
220
+ heartbeatTimer = null;
221
+ sampleTimer = null;
222
+ }
223
+ function startHeartbeat(target) {
224
+ if (heartbeatTimer)
225
+ clearInterval(heartbeatTimer);
226
+ heartbeatTimer = setInterval(() => void sendHeartbeat(target), intervalToMs(intervalSec));
227
+ }
228
+ function applyConfiguration(frame, target) {
229
+ const previousInterval = intervalSec;
230
+ intervalSec = clampIntervalSec(frame.interval_sec);
231
+ watchedServices = [...frame.watched_services];
232
+ if (nginxEnabled !== frame.nginx_enabled) {
233
+ nginxEnabled = frame.nginx_enabled;
234
+ nginxDirty = true;
235
+ }
236
+ if (frame.type === "welcome" || previousInterval !== intervalSec)
237
+ startHeartbeat(target);
238
+ }
239
+ async function respond(frame, target) {
240
+ let response = null;
241
+ try {
242
+ response = await handlers.handle(frame);
243
+ }
244
+ catch {
245
+ response = errorResponse(frame.request_id, "internal", "internal");
246
+ }
247
+ if (!response)
248
+ return;
249
+ let text;
250
+ try {
251
+ text = JSON.stringify(ResponseFrameSchema.parse(response));
252
+ }
253
+ catch {
254
+ text = JSON.stringify(errorResponse(frame.request_id, "internal", "internal"));
255
+ }
256
+ if (Buffer.byteLength(text) > MAX_RESPONSE_BYTES) {
257
+ text = JSON.stringify(errorResponse(frame.request_id, "too_large", "too_large"));
258
+ }
259
+ send(target, text);
260
+ }
261
+ function onMessage(target, data, binary) {
262
+ if (binary) {
263
+ log("warn", "binary_frame_ignored");
264
+ return;
265
+ }
266
+ const buffer = Array.isArray(data) ? Buffer.concat(data) : Buffer.isBuffer(data) ? data : Buffer.from(data);
267
+ const parsed = parseGatewayFrame(buffer.toString("utf8"));
268
+ if (!parsed.ok) {
269
+ log("warn", "invalid_frame_ignored");
270
+ return;
271
+ }
272
+ switch (parsed.frame.type) {
273
+ case "welcome":
274
+ attempt = 0;
275
+ applyConfiguration(parsed.frame, target);
276
+ log("info", "welcome", { interval_sec: intervalSec, generation: parsed.frame.connection_generation });
277
+ return;
278
+ case "config":
279
+ applyConfiguration(parsed.frame, target);
280
+ log("info", "config_applied", { interval_sec: intervalSec, watched_services: watchedServices.length, nginx_enabled: nginxEnabled });
281
+ return;
282
+ case "request":
283
+ void respond(parsed.frame, target);
284
+ return;
285
+ default:
286
+ log("warn", "invalid_frame_ignored");
287
+ }
288
+ }
289
+ function scheduleReconnect(code) {
290
+ if (stopped)
291
+ return;
292
+ const delayMs = backoffDelayMs(attempt, random);
293
+ attempt++;
294
+ log("info", "reconnecting", { attempt, delay_ms: delayMs, code });
295
+ reconnectTimer = setTimeout(() => { reconnectTimer = null; connect(); }, delayMs);
296
+ }
297
+ function connect() {
298
+ if (stopped)
299
+ return;
300
+ const target = new WebSocket(options.config.endpoint, {
301
+ headers: { Authorization: `Bearer ${options.config.token}`, "X-Agent-Version": agentVersion },
302
+ perMessageDeflate: false,
303
+ handshakeTimeout: options.handshakeTimeoutMs ?? HANDSHAKE_TIMEOUT_MS,
304
+ });
305
+ socket = target;
306
+ seq = 0;
307
+ samples = [];
308
+ target.on("open", () => {
309
+ log("info", "connected");
310
+ sampleTimer = setInterval(() => void sample(), sampleIntervalMs);
311
+ void sendHello(target).catch(() => {
312
+ log("warn", "hello_failed");
313
+ target.close(1011, "hello_failed");
314
+ });
315
+ });
316
+ target.on("message", (data, binary) => { if (socket === target)
317
+ onMessage(target, data, binary); });
318
+ target.on("error", () => log("warn", "socket_error"));
319
+ target.on("close", (code) => {
320
+ if (socket !== target)
321
+ return;
322
+ socket = null;
323
+ stopTimers();
324
+ log("info", "disconnected", { code });
325
+ scheduleReconnect(code);
326
+ });
327
+ }
328
+ return {
329
+ get connected() { return socket !== null && socket.readyState === WebSocket.OPEN; },
330
+ get seq() { return seq; },
331
+ get intervalSec() { return intervalSec; },
332
+ get heartbeatIntervalMs() { return intervalToMs(intervalSec); },
333
+ start() {
334
+ if (!stopped)
335
+ return;
336
+ stopped = false;
337
+ connect();
338
+ },
339
+ async stop() {
340
+ stopped = true;
341
+ stopTimers();
342
+ if (reconnectTimer) {
343
+ clearTimeout(reconnectTimer);
344
+ reconnectTimer = null;
345
+ }
346
+ const target = socket;
347
+ socket = null;
348
+ if (!target) {
349
+ log("info", "stopped");
350
+ return;
351
+ }
352
+ await new Promise((resolve) => {
353
+ const done = setTimeout(() => { target.terminate(); resolve(); }, CLOSE_GRACE_MS);
354
+ target.once("close", () => { clearTimeout(done); resolve(); });
355
+ if (target.readyState === WebSocket.OPEN || target.readyState === WebSocket.CONNECTING)
356
+ target.close(1000, "shutdown");
357
+ else {
358
+ clearTimeout(done);
359
+ resolve();
360
+ }
361
+ });
362
+ log("info", "stopped");
363
+ },
364
+ };
365
+ }
@@ -0,0 +1,13 @@
1
+ import type { Capabilities, CapabilityState } from "../protocol.js";
2
+ export declare function collectCapabilities(states: {
3
+ readonly host: CapabilityState;
4
+ readonly pm2: {
5
+ readonly capability: CapabilityState;
6
+ };
7
+ readonly systemd: {
8
+ readonly capability: CapabilityState;
9
+ };
10
+ readonly nginx: {
11
+ readonly capability: CapabilityState;
12
+ };
13
+ }): Capabilities;
@@ -0,0 +1,3 @@
1
+ export function collectCapabilities(states) {
2
+ return { host: states.host, pm2: states.pm2.capability, systemd: states.systemd.capability, nginx: states.nginx.capability };
3
+ }
@@ -0,0 +1,13 @@
1
+ import type { ExecFileOptions } from "node:child_process";
2
+ import type { CapabilityState, ResponseError } from "../protocol.js";
3
+ export declare function run(file: string, args: readonly string[], options?: Omit<ExecFileOptions, "shell">): Promise<{
4
+ stdout: string;
5
+ stderr: string;
6
+ }>;
7
+ export declare function locate(binary: string): Promise<string | null>;
8
+ export declare class CollectionError extends Error {
9
+ readonly code: ResponseError["code"];
10
+ constructor(code: ResponseError["code"]);
11
+ }
12
+ export declare function responseError(error: unknown): CollectionError;
13
+ export declare function capabilityError(error: unknown): CapabilityState;