UNPKG

@bsv/sdk

Version:

BSV Blockchain Software Development Kit

233 lines (211 loc) 7.29 kB
interface HostReputationEntry { host: string totalSuccesses: number totalFailures: number consecutiveFailures: number avgLatencyMs: number | null lastLatencyMs: number | null backoffUntil: number lastUpdatedAt: number lastError?: string } export interface RankedHost extends HostReputationEntry { score: number } const DEFAULT_LATENCY_MS = 1500 const LATENCY_SMOOTHING_FACTOR = 0.25 const BASE_BACKOFF_MS = 1000 const MAX_BACKOFF_MS = 60_000 const FAILURE_PENALTY_MS = 400 const SUCCESS_BONUS_MS = 30 const FAILURE_BACKOFF_GRACE = 2 const STORAGE_KEY = 'bsvsdk_overlay_host_reputation_v1' interface KeyValueStore { get: (key: string) => string | null | undefined set: (key: string, value: string) => void } export class HostReputationTracker { private readonly stats: Map<string, HostReputationEntry> private readonly store: KeyValueStore | undefined constructor (store?: KeyValueStore) { this.stats = new Map() this.store = store ?? this.getLocalStorageAdapter() this.loadFromStorage() } reset (): void { this.stats.clear() } recordSuccess (host: string, latencyMs: number): void { const entry = this.getOrCreate(host) const now = Date.now() const safeLatency = Number.isFinite(latencyMs) && latencyMs >= 0 ? latencyMs : DEFAULT_LATENCY_MS if (entry.avgLatencyMs === null) { entry.avgLatencyMs = safeLatency } else { entry.avgLatencyMs = (1 - LATENCY_SMOOTHING_FACTOR) * entry.avgLatencyMs + LATENCY_SMOOTHING_FACTOR * safeLatency } entry.lastLatencyMs = safeLatency entry.totalSuccesses += 1 entry.consecutiveFailures = 0 entry.backoffUntil = 0 entry.lastUpdatedAt = now entry.lastError = undefined this.saveToStorage() } recordFailure (host: string, reason?: unknown): void { const entry = this.getOrCreate(host) const now = Date.now() entry.totalFailures += 1 entry.consecutiveFailures += 1 const msg = typeof reason === 'string' ? reason : reason instanceof Error ? reason.message : undefined const immediate = typeof msg === 'string' && (msg.includes('ERR_NAME_NOT_RESOLVED') || msg.includes('ENOTFOUND') || msg.includes('getaddrinfo') || msg.includes('Failed to fetch')) if (immediate && entry.consecutiveFailures < FAILURE_BACKOFF_GRACE + 1) { entry.consecutiveFailures = FAILURE_BACKOFF_GRACE + 1 } const penaltyLevel = Math.max(entry.consecutiveFailures - FAILURE_BACKOFF_GRACE, 0) if (penaltyLevel === 0) { entry.backoffUntil = 0 } else { const backoffDuration = Math.min( MAX_BACKOFF_MS, BASE_BACKOFF_MS * Math.pow(2, penaltyLevel - 1) ) entry.backoffUntil = now + backoffDuration } entry.lastUpdatedAt = now entry.lastError = typeof reason === 'string' ? reason : reason instanceof Error ? reason.message : undefined this.saveToStorage() } rankHosts (hosts: string[], now: number = Date.now()): RankedHost[] { const seen = new Map<string, number>() hosts.forEach((host, idx) => { if (typeof host !== 'string' || host.length === 0) return if (!seen.has(host)) seen.set(host, idx) }) const orderedHosts = Array.from(seen.keys()) const ranked = orderedHosts.map((host) => { const entry = this.getOrCreate(host) return { ...entry, score: this.computeScore(entry, now), originalOrder: seen.get(host) ?? 0 } }) ranked.sort((a, b) => { const aInBackoff = a.backoffUntil > now const bInBackoff = b.backoffUntil > now if (aInBackoff !== bInBackoff) return aInBackoff ? 1 : -1 if (a.score !== b.score) return a.score - b.score if (a.totalSuccesses !== b.totalSuccesses) return b.totalSuccesses - a.totalSuccesses return (a as any).originalOrder - (b as any).originalOrder }) return ranked.map(({ originalOrder, ...rest }) => rest) } snapshot (host: string): HostReputationEntry | undefined { const entry = this.stats.get(host) return entry != null ? { ...entry } : undefined } private getStorage (): any { try { const g: any = typeof globalThis === 'object' ? globalThis : undefined if (g == null || g.localStorage == null) return undefined return g.localStorage } catch { return undefined } } private getLocalStorageAdapter (): KeyValueStore | undefined { const s = this.getStorage() if (s == null) return undefined return { get: (key: string) => { try { return s.getItem(key) } catch { return null } }, set: (key: string, value: string) => { try { s.setItem(key, value) } catch { } } } } private loadFromStorage (): void { const s = this.store if (s == null) return try { const raw = s.get(STORAGE_KEY) if (typeof raw !== 'string' || raw.length === 0) return const data = JSON.parse(raw) if (typeof data !== 'object' || data === null) return this.stats.clear() for (const k of Object.keys(data)) { const v: any = (data)[k] if (v != null && typeof v === 'object') { const entry: HostReputationEntry = { host: String(v.host ?? k), totalSuccesses: Number(v.totalSuccesses ?? 0), totalFailures: Number(v.totalFailures ?? 0), consecutiveFailures: Number(v.consecutiveFailures ?? 0), avgLatencyMs: v.avgLatencyMs == null ? null : Number(v.avgLatencyMs), lastLatencyMs: v.lastLatencyMs == null ? null : Number(v.lastLatencyMs), backoffUntil: Number(v.backoffUntil ?? 0), lastUpdatedAt: Number(v.lastUpdatedAt ?? 0), lastError: typeof v.lastError === 'string' ? v.lastError : undefined } this.stats.set(entry.host, entry) } } } catch {} } private saveToStorage (): void { const s = this.store if (s == null) return try { const obj: Record<string, any> = {} for (const [host, entry] of this.stats.entries()) { obj[host] = entry } s.set(STORAGE_KEY, JSON.stringify(obj)) } catch {} } private computeScore (entry: HostReputationEntry, now: number): number { const latency = entry.avgLatencyMs ?? DEFAULT_LATENCY_MS const failurePenalty = entry.consecutiveFailures * FAILURE_PENALTY_MS const successBonus = Math.min(entry.totalSuccesses * SUCCESS_BONUS_MS, latency / 2) const backoffPenalty = entry.backoffUntil > now ? entry.backoffUntil - now : 0 return latency + failurePenalty + backoffPenalty - successBonus } private getOrCreate (host: string): HostReputationEntry { let entry = this.stats.get(host) if (entry == null) { entry = { host, totalSuccesses: 0, totalFailures: 0, consecutiveFailures: 0, avgLatencyMs: null, lastLatencyMs: null, backoffUntil: 0, lastUpdatedAt: 0 } this.stats.set(host, entry) } return entry } } const globalTracker = new HostReputationTracker() export const getOverlayHostReputationTracker = (): HostReputationTracker => globalTracker