Skip to main content
Glama
heartbeat.ts4.93 kB
import fs from 'node:fs/promises'; import path from 'node:path'; import timers from 'node:timers/promises'; /** * Configuration for heartbeat system. */ export type HeartbeatConfig = { /** Directory where heartbeat file is stored */ directory: string; /** Name of the heartbeat file (default: heartbeat.txt) */ filename?: string; /** Interval in milliseconds (default: 1000ms) */ interval?: number; }; /** * Status of the monitored target. */ export type HeartbeatStatus = 'unknown' | 'alive' | 'dead' | 'completed'; /** * Heartbeat generator that emits timestamps to a file at regular intervals. */ export class HeartbeatGenerator { private readonly config: Required<HeartbeatConfig>; private readonly filePath: string; private intervalId: NodeJS.Timeout | null = null; private isRunning = false; constructor(config: HeartbeatConfig) { this.config = { interval: 1000, filename: 'heartbeat.txt', ...config, }; this.filePath = path.join(this.config.directory, this.config.filename); } /** * Start the heartbeat generator. */ async start(): Promise<void> { if (this.isRunning) { return; } this.isRunning = true; // Create directory if it doesn't exist await fs.mkdir(this.config.directory, {recursive: true}); // Emit initial heartbeat await this.emitHeartbeat(); // Start interval for subsequent heartbeats this.intervalId = setInterval(async () => { if (this.isRunning) { await this.emitHeartbeat(); } }, this.config.interval); } /** * Stop the heartbeat generator. */ async stop(): Promise<void> { if (!this.isRunning) { return; } this.isRunning = false; if (this.intervalId) { clearInterval(this.intervalId); this.intervalId = null; } // Remove heartbeat file to signal completion try { await fs.unlink(this.filePath); } catch { // Ignore errors if file doesn't exist } } /** * Emit a heartbeat by writing current timestamp to file. */ private async emitHeartbeat(): Promise<void> { const timestamp = Date.now(); await fs.writeFile(this.filePath, timestamp.toString(), 'utf8'); } /** * Check if the generator is currently running. */ get running(): boolean { return this.isRunning; } } /** * Heartbeat watcher that monitors file changes to detect process health. */ export class HeartbeatWatcher { private readonly config: Required<HeartbeatConfig>; private readonly filePath: string; #isWatching = false; #status: HeartbeatStatus = 'unknown'; #lastHeartbeat = 0; #missedHeartbeats = 0; constructor(config: HeartbeatConfig) { this.config = { interval: 1000, filename: 'heartbeat.txt', ...config, }; this.filePath = path.join(this.config.directory, this.config.filename); } /** * Start watching for heartbeats. Promise is resolved when either heartbeat is * missed or target is completed. */ async start(signal: AbortSignal): Promise<void> { if (this.#isWatching) { return; } this.#isWatching = true; // 5x seems reasonably enough to allow heartbeat to start, but // it might require adjustment in the future. await timers.setTimeout(this.config.interval * 5); // start polling heartbeat file for await (const _ of timers.setInterval(this.config.interval)) { if (!this.#isWatching || signal.aborted) { return; } const heartbeat = await this.readHeartbeat(); if (heartbeat === null && this.#lastHeartbeat === 0) { // we've already waited for sometime, if file not created - we // assume it is dead this.#status = 'dead'; return; } if (heartbeat === null) { // here we already had previous heartbeat, and file is gone // we assume terminal exited "normally" this.#status = 'completed'; return; } if (heartbeat > this.#lastHeartbeat) { // heartbeat is newer than previous one, so it is alive this.#status = 'alive'; this.#missedHeartbeats = 0; this.#lastHeartbeat = heartbeat; continue; } // heartbeat not changed - treat as a miss this.#missedHeartbeats++; if (this.#missedHeartbeats > 2) { // we've missed too many heartbeats, so we assume it is dead this.#status = 'dead'; return; } } } /** * Stop watching for heartbeats. */ async stop(): Promise<void> { this.#isWatching = false; } /** * Read heartbeat from file. * * @returns timestamp or null if file doesn't exist */ private async readHeartbeat(): Promise<number | null> { try { const content = await fs.readFile(this.filePath, 'utf8'); return Number.parseInt(content.trim(), 10); } catch { return null; } } /** * Check if the watcher is currently running. */ get isWatching(): boolean { return this.#isWatching; } /** * Get the current number of missed heartbeats. */ get missedBeats(): number { return this.#missedHeartbeats; } get status(): HeartbeatStatus { return this.#status; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/ver0-project/mcps'

If you have feedback or need assistance with the MCP directory API, please join our Discord server