UNPKG

@bsv/overlay-express

Version:
248 lines (217 loc) โ€ข 8.25 kB
import { Db } from 'mongodb' import chalk from 'chalk' /** * Configuration for the Janitor Service */ export interface JanitorConfig { mongoDb: Db logger?: typeof console requestTimeoutMs?: number hostDownRevokeScore?: number } /** * JanitorService runs a single pass of health checks on SHIP and SLAP outputs. * It validates domain names and checks /health endpoints to ensure services are operational. * * When a service is down, it increments a "down" counter. When healthy, it decrements. * If the down counter reaches HOST_DOWN_REVOKE_SCORE, it deletes the output from the database. * * This service is designed to be run periodically via external schedulers (e.g., cron, docker-compose). */ export class JanitorService { private readonly mongoDb: Db private readonly logger: typeof console private readonly requestTimeoutMs: number private readonly hostDownRevokeScore: number constructor (config: JanitorConfig) { this.mongoDb = config.mongoDb this.logger = config.logger ?? console this.requestTimeoutMs = config.requestTimeoutMs ?? 10000 // Default: 10 seconds this.hostDownRevokeScore = config.hostDownRevokeScore ?? 3 } /** * Runs a single pass of health checks on all SHIP and SLAP outputs */ async run (): Promise<void> { this.logger.log(chalk.blue('๐Ÿงน Running janitor health checks...')) try { // Check SHIP outputs await this.checkTopicOutputs('shipRecords') // Check SLAP outputs await this.checkTopicOutputs('slapRecords') this.logger.log(chalk.green('๐Ÿงน Janitor health checks completed')) } catch (error) { this.logger.error(chalk.red('โŒ Error during health checks:'), error) throw error } } /** * Checks all outputs for a specific topic */ private async checkTopicOutputs (collectionName: string): Promise<void> { try { const collection = this.mongoDb.collection(collectionName) const outputs = await collection.find({}).toArray() this.logger.log(chalk.cyan(`๐Ÿ” Checking ${outputs.length} ${collectionName.toUpperCase()} outputs...`)) for (const output of outputs) { await this.checkOutput(output, collection) } } catch (error) { this.logger.error(chalk.red(`โŒ Error checking ${collectionName.toUpperCase()} outputs:`), error) } } /** * Checks a single output for health */ private async checkOutput (output: Record<string, any>, collection: any): Promise<void> { try { // Extract the URL from the output const url = this.extractURLFromOutput(output) if (url === null) { return // Skip if no valid URL } // Validate domain if (!this.isValidDomain(url)) { this.logger.log(chalk.yellow(`โš ๏ธ Invalid domain for output ${String(output.txid)}:${String(output.outputIndex)}: ${url}`)) await this.handleUnhealthyOutput(output, collection) return } // Check health endpoint const isHealthy = await this.checkHealthEndpoint(url) if (isHealthy) { await this.handleHealthyOutput(output, collection) } else { await this.handleUnhealthyOutput(output, collection) } } catch (error) { this.logger.error(chalk.red(`โŒ Error checking output ${String(output.txid)}:${String(output.outputIndex)}:`), error) await this.handleUnhealthyOutput(output, collection).catch(() => {}) } } /** * Extracts URL from output record */ private extractURLFromOutput (output: Record<string, any>): string | null { try { // SHIP and SLAP outputs typically have a domain field or URL field // Check for common field names if (typeof output.domain === 'string') { return output.domain } if (typeof output.url === 'string') { return output.url } if (typeof output.serviceURL === 'string') { return output.serviceURL } // SLAP outputs may have a protocols array with URLs if (Array.isArray(output.protocols) && output.protocols.length > 0) { const httpsProtocol = output.protocols.find((p: any) => typeof p === 'string' && p.startsWith('https://') ) if (httpsProtocol !== undefined) { return httpsProtocol } } return null } catch { return null } } /** * Validates if a string is a valid domain name */ private isValidDomain (url: string): boolean { try { // Parse the URL const parsedURL = new URL(url.startsWith('http') ? url : `https://${url}`) // Check if hostname is valid const hostname = parsedURL.hostname // Basic domain validation const domainRegex = /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$/i const localhostRegex = /^localhost$/i const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/ return domainRegex.test(hostname) || localhostRegex.test(hostname) || ipv4Regex.test(hostname) } catch { return false } } /** * Checks the /health endpoint of a service */ private async checkHealthEndpoint (url: string): Promise<boolean> { try { // Ensure URL has protocol const fullURL = url.startsWith('http') ? url : `https://${url}` const healthURL = new URL('/health', fullURL).toString() // Create AbortController for timeout const controller = new AbortController() const timeout = setTimeout(() => controller.abort(), this.requestTimeoutMs) try { const response = await fetch(healthURL, { method: 'GET', signal: controller.signal, headers: { Accept: 'application/json' } }) clearTimeout(timeout) if (!response.ok) { return false } const data = await response.json() return data?.status === 'ok' } catch (error: any) { clearTimeout(timeout) if (error.name === 'AbortError') { this.logger.log(chalk.yellow(`โฑ๏ธ Health check timeout for ${healthURL}`)) } return false } } catch (error) { return false } } /** * Handles a healthy output by decrementing its down counter */ private async handleHealthyOutput (output: Record<string, any>, collection: any): Promise<void> { try { const currentDown = typeof output.down === 'number' ? output.down : 0 if (currentDown > 0) { // Decrement the down counter await collection.updateOne( { _id: output._id }, { $inc: { down: -1 } } ) this.logger.log(chalk.green(`โœ… Output ${String(output.txid)}:${String(output.outputIndex)} is healthy (down: ${currentDown} -> ${currentDown - 1})`)) } } catch (error) { this.logger.error(chalk.red(`โŒ Error handling healthy output ${String(output.txid)}:${String(output.outputIndex)}:`), error) } } /** * Handles an unhealthy output by incrementing its down counter and deleting if threshold is reached */ private async handleUnhealthyOutput (output: Record<string, any>, collection: any): Promise<void> { try { const currentDown = typeof output.down === 'number' ? output.down : 0 const newDown = currentDown + 1 this.logger.log(chalk.yellow(`โš ๏ธ Output ${String(output.txid)}:${String(output.outputIndex)} is unhealthy (down: ${currentDown} -> ${newDown})`)) // Check if we should delete the record if (newDown >= this.hostDownRevokeScore) { this.logger.log(chalk.red(`๐Ÿšซ Deleting output ${String(output.txid)}:${String(output.outputIndex)} (down count: ${newDown} >= ${this.hostDownRevokeScore})`)) await collection.deleteOne({ _id: output._id }) this.logger.log(chalk.green(`โœ… Successfully deleted output ${String(output.txid)}:${String(output.outputIndex)}`)) } else { // Increment the down counter await collection.updateOne( { _id: output._id }, { $inc: { down: 1 } } ) } } catch (error) { this.logger.error(chalk.red(`โŒ Error handling unhealthy output ${String(output.txid)}:${String(output.outputIndex)}:`), error) } } }