UNPKG

@bsv/overlay-express

Version:
578 lines 23.5 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.OverlayMonitor = void 0; exports.analyzeOverlayAnchorTip = analyzeOverlayAnchorTip; exports.analyzeOverlayLookupResponse = analyzeOverlayLookupResponse; const sdk_1 = require("@bsv/sdk"); const defaultThresholds = { responseBytes: 1024 * 1024, beefBytes: 64 * 1024, txsWithoutProof: 1, requireSubjectProof: true, anchorTipLagBlocks: 6, expiredUnprovenCount: 0 }; /** * OverlayMonitor runs generic Overlay Express /lookup probes and reports response * size plus BEEF proof shape. It is intended for long-running monitor workers or * scheduled jobs, analogous to a storage monitor but scoped to overlay health. */ class OverlayMonitor { constructor(config) { var _a, _b, _c, _d; this.running = false; this.targets = config.targets; this.thresholds = { ...defaultThresholds, ...config.thresholds }; // Bind to globalThis so calling through this.fetchImpl does not rebind `this` // (browser fetch throws "Illegal invocation" when invoked as a method). this.fetchImpl = (_a = config.fetchImpl) !== null && _a !== void 0 ? _a : fetch.bind(globalThis); this.logger = (_b = config.logger) !== null && _b !== void 0 ? _b : console; this.onReport = config.onReport; this.now = (_c = config.now) !== null && _c !== void 0 ? _c : (() => new Date()); this.intervalMs = config.intervalMs; this.timeoutMs = (_d = config.timeoutMs) !== null && _d !== void 0 ? _d : 30000; } async runOnce() { var _a, _b; const startedAt = this.now(); const results = []; const anchorResults = []; const maintenanceResults = []; for (const target of this.targets) { for (const probe of target.probes) { results.push(await this.runProbe(target, probe)); } for (const probe of (_a = target.anchorProbes) !== null && _a !== void 0 ? _a : []) { anchorResults.push(await this.runAnchorProbe(target, probe)); } maintenanceResults.push(...await this.runMaintenance(target)); } const completedAt = this.now(); const report = { startedAt, completedAt, durationMs: completedAt.getTime() - startedAt.getTime(), results, anchorResults, maintenanceResults, summary: summarize(results, anchorResults, maintenanceResults, this.targets.length) }; await ((_b = this.onReport) === null || _b === void 0 ? void 0 : _b.call(this, report)); return report; } start() { if (this.intervalMs === undefined) { throw new Error('intervalMs is required to start OverlayMonitor'); } if (this.timer !== undefined) return; this.timer = setInterval(() => { if (this.running) return; this.running = true; this.runOnce() .catch(error => { this.logger.error('OverlayMonitor run failed', error); }) .finally(() => { this.running = false; }); }, this.intervalMs); } stop() { if (this.timer !== undefined) { clearInterval(this.timer); this.timer = undefined; } } async runProbe(target, probe) { var _a; const startedAt = this.now(); const url = new URL('/lookup', target.baseUrl).toString(); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), this.timeoutMs); try { const response = await this.fetchImpl(url, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', ...target.headers }, body: JSON.stringify({ service: probe.service, query: probe.query }), signal: controller.signal }); const text = await response.text(); const responseBytes = new TextEncoder().encode(text).length; const completedAt = this.now(); let body; try { body = text.length === 0 ? {} : JSON.parse(text); } catch (error) { return makeFailedResult({ target, probe, url, status: response.status, ok: response.ok, responseBytes, durationMs: completedAt.getTime() - startedAt.getTime(), error: error instanceof Error ? error.message : 'Invalid JSON response' }); } return analyzeOverlayLookupResponse({ target: target.name, url, probe: (_a = probe.name) !== null && _a !== void 0 ? _a : probe.service, service: probe.service, status: response.status, ok: response.ok, responseBody: body, responseBytes, durationMs: completedAt.getTime() - startedAt.getTime(), thresholds: this.thresholds, maxOutputs: probe.maxOutputs }); } catch (error) { const completedAt = this.now(); const fallbackMessage = error instanceof Error ? error.message : 'Lookup probe failed'; const message = controller.signal.aborted ? `Lookup probe timed out after ${this.timeoutMs}ms` : fallbackMessage; return makeFailedResult({ target, probe, url, status: 0, ok: false, responseBytes: 0, durationMs: completedAt.getTime() - startedAt.getTime(), error: message }); } finally { clearTimeout(timeout); } } async runAnchorProbe(target, probe) { var _a; const startedAt = this.now(); const url = new URL('/requestTopicAnchorTip', target.baseUrl).toString(); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), this.timeoutMs); try { const response = await this.fetchImpl(url, { method: 'POST', headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'x-bsv-topic': probe.topic, ...target.headers }, body: JSON.stringify({}), signal: controller.signal }); const text = await response.text(); const responseBytes = new TextEncoder().encode(text).length; const completedAt = this.now(); let body; try { body = text.length === 0 ? {} : JSON.parse(text); } catch (error) { return makeFailedAnchorResult({ target, probe, url, status: response.status, ok: response.ok, responseBytes, durationMs: completedAt.getTime() - startedAt.getTime(), error: error instanceof Error ? error.message : 'Invalid JSON response' }); } return analyzeOverlayAnchorTip({ target: target.name, url, probe: (_a = probe.name) !== null && _a !== void 0 ? _a : probe.topic, topic: probe.topic, status: response.status, ok: response.ok, responseBody: body, responseBytes, durationMs: completedAt.getTime() - startedAt.getTime(), thresholds: this.thresholds, expectedTac: probe.expectedTac, expectedBlockHeight: probe.expectedBlockHeight, currentHeight: probe.currentHeight, maxTipLagBlocks: probe.maxTipLagBlocks }); } catch (error) { const completedAt = this.now(); const fallbackMessage = error instanceof Error ? error.message : 'Anchor probe failed'; const message = controller.signal.aborted ? `Anchor probe timed out after ${this.timeoutMs}ms` : fallbackMessage; return makeFailedAnchorResult({ target, probe, url, status: 0, ok: false, responseBytes: 0, durationMs: completedAt.getTime() - startedAt.getTime(), error: message }); } finally { clearTimeout(timeout); } } async runMaintenance(target) { const maintenance = target.maintenance; if (maintenance === undefined) return []; const results = []; if (maintenance.startBASMSync === true) { results.push(await this.runMaintenanceRequest(target, maintenance, 'start-basm-sync', '/admin/startBASMSync', {})); } if (maintenance.maintainUnproven !== undefined && maintenance.maintainUnproven !== false) { const config = maintenance.maintainUnproven === true ? {} : maintenance.maintainUnproven; const topics = config.topics !== undefined && config.topics.length > 0 ? config.topics : [undefined]; for (const topic of topics) { results.push(await this.runMaintenanceRequest(target, maintenance, 'maintain-unproven', '/admin/maintainUnproven', { topic, thresholdBlocks: config.thresholdBlocks })); } } if (maintenance.janitor === true) { results.push(await this.runMaintenanceRequest(target, maintenance, 'janitor', '/admin/janitor', {})); } return results; } async runMaintenanceRequest(target, maintenance, operation, path, body) { const startedAt = this.now(); const url = new URL(path, target.baseUrl).toString(); const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), this.timeoutMs); try { const headers = { Accept: 'application/json', 'Content-Type': 'application/json', ...target.headers, ...maintenance.headers }; if (maintenance.adminToken !== undefined && maintenance.adminToken !== '') { headers.Authorization = `Bearer ${maintenance.adminToken}`; } const response = await this.fetchImpl(url, { method: 'POST', headers, body: JSON.stringify(stripUndefined(body)), signal: controller.signal }); const text = await response.text(); const responseBytes = new TextEncoder().encode(text).length; const completedAt = this.now(); let parsed; try { parsed = text.length === 0 ? {} : JSON.parse(text); } catch (error) { return { target: target.name, url, operation, topic: body.topic, status: response.status, ok: false, responseBytes, durationMs: completedAt.getTime() - startedAt.getTime(), error: error instanceof Error ? error.message : 'Invalid JSON response' }; } return { target: target.name, url, operation, topic: body.topic, status: response.status, ok: response.ok, responseBytes, durationMs: completedAt.getTime() - startedAt.getTime(), data: parsed, error: response.ok ? undefined : readErrorMessage(parsed) }; } catch (error) { const completedAt = this.now(); const fallbackMessage = error instanceof Error ? error.message : 'Maintenance request failed'; const message = controller.signal.aborted ? `Maintenance request timed out after ${this.timeoutMs}ms` : fallbackMessage; return { target: target.name, url, operation, topic: body.topic, status: 0, ok: false, responseBytes: 0, durationMs: completedAt.getTime() - startedAt.getTime(), error: message }; } finally { clearTimeout(timeout); } } } exports.OverlayMonitor = OverlayMonitor; function analyzeOverlayAnchorTip(options) { var _a, _b; const thresholds = { ...defaultThresholds, ...options.thresholds }; const blockHeight = typeof options.responseBody.blockHeight === 'number' ? options.responseBody.blockHeight : undefined; const tac = typeof options.responseBody.tac === 'string' ? options.responseBody.tac : undefined; let expiredUnprovenCount; if (typeof options.responseBody.expiredUnprovenCount === 'number') { expiredUnprovenCount = options.responseBody.expiredUnprovenCount; } else if (typeof options.responseBody.unprovenExpiredCount === 'number') { expiredUnprovenCount = options.responseBody.unprovenExpiredCount; } else { expiredUnprovenCount = undefined; } const warnings = []; if (options.expectedTac !== undefined && tac !== undefined && tac !== options.expectedTac) { warnings.push({ code: 'anchor-tip-mismatch', message: 'Topic anchor TAC does not match expected value', value: true, threshold: false }); } const expectedHeight = (_a = options.expectedBlockHeight) !== null && _a !== void 0 ? _a : options.currentHeight; const maxLag = (_b = options.maxTipLagBlocks) !== null && _b !== void 0 ? _b : thresholds.anchorTipLagBlocks; if (expectedHeight !== undefined && blockHeight !== undefined && expectedHeight - blockHeight > maxLag) { warnings.push({ code: 'anchor-stale-height', message: `Topic anchor tip is ${expectedHeight - blockHeight} blocks behind`, value: expectedHeight - blockHeight, threshold: maxLag }); } if (expiredUnprovenCount !== undefined && expiredUnprovenCount > thresholds.expiredUnprovenCount) { warnings.push({ code: 'expired-unproven-state', message: `Overlay reports ${expiredUnprovenCount} expired unproven transactions`, value: expiredUnprovenCount, threshold: thresholds.expiredUnprovenCount }); } return { target: options.target, url: options.url, probe: options.probe, topic: options.topic, status: options.status, ok: options.ok, responseBytes: options.responseBytes, blockHeight, tac, warnings, durationMs: options.durationMs }; } function analyzeOverlayLookupResponse(options) { var _a; const thresholds = { ...defaultThresholds, ...options.thresholds }; const outputs = extractOutputs(options.responseBody); const maxOutputs = (_a = options.maxOutputs) !== null && _a !== void 0 ? _a : outputs.length; const analyzedOutputs = outputs.slice(0, maxOutputs).map(analyzeOutput); const warnings = []; if (options.responseBytes >= thresholds.responseBytes) { warnings.push({ code: 'response-bytes', message: `Lookup response is ${options.responseBytes} bytes`, value: options.responseBytes, threshold: thresholds.responseBytes }); } for (const output of analyzedOutputs) { addOutputWarnings(warnings, output, thresholds); } return { target: options.target, url: options.url, probe: options.probe, service: options.service, status: options.status, ok: options.ok, responseBytes: options.responseBytes, outputCount: outputs.length, analyzedOutputCount: analyzedOutputs.length, outputs: analyzedOutputs, warnings, durationMs: options.durationMs, error: options.error }; } function makeFailedResult(options) { var _a; return { target: options.target.name, url: options.url, probe: (_a = options.probe.name) !== null && _a !== void 0 ? _a : options.probe.service, service: options.probe.service, status: options.status, ok: options.ok, responseBytes: options.responseBytes, outputCount: 0, analyzedOutputCount: 0, outputs: [], warnings: [], durationMs: options.durationMs, error: options.error }; } function makeFailedAnchorResult(options) { var _a; return { target: options.target.name, url: options.url, probe: (_a = options.probe.name) !== null && _a !== void 0 ? _a : options.probe.topic, topic: options.probe.topic, status: options.status, ok: options.ok, responseBytes: options.responseBytes, warnings: [], durationMs: options.durationMs, error: options.error }; } function summarize(results, anchorResults, maintenanceResults, targetCount) { return { targetCount, probeCount: results.length, anchorProbeCount: anchorResults.length, maintenanceActionCount: maintenanceResults.length, failedProbeCount: results.filter(result => !result.ok || result.error !== undefined).length + anchorResults.filter(result => !result.ok || result.error !== undefined).length, failedMaintenanceActionCount: maintenanceResults.filter(result => !result.ok || result.error !== undefined).length, responseBytes: results.reduce((sum, result) => sum + result.responseBytes, 0) + anchorResults.reduce((sum, result) => sum + result.responseBytes, 0) + maintenanceResults.reduce((sum, result) => sum + result.responseBytes, 0), outputCount: results.reduce((sum, result) => sum + result.outputCount, 0), analyzedOutputCount: results.reduce((sum, result) => sum + result.analyzedOutputCount, 0), outputsMissingSubjectProof: results.reduce((sum, result) => { return sum + result.outputs.filter(output => output.subjectHasProof === false).length; }, 0), warningCount: results.reduce((sum, result) => sum + result.warnings.length, 0) + anchorResults.reduce((sum, result) => sum + result.warnings.length, 0) }; } function stripUndefined(value) { return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)); } function readErrorMessage(body) { if (typeof body !== 'object' || body === null) return undefined; const message = body.message; return typeof message === 'string' ? message : undefined; } function extractOutputs(body) { if (typeof body !== 'object' || body === null || !('outputs' in body)) return []; const outputs = body.outputs; if (!Array.isArray(outputs)) return []; return outputs.filter((output) => typeof output === 'object' && output !== null); } function analyzeOutput(output) { var _a, _b, _c; const outputIndex = typeof output.outputIndex === 'number' ? output.outputIndex : -1; const beef = readNumberArray(output.beef); const context = readNumberArray(output.context); if (beef === undefined) { return { outputIndex, beefBytes: 0, contextBytes: context === null || context === void 0 ? void 0 : context.length, error: 'Output did not include numeric BEEF' }; } try { const subjectTx = sdk_1.Transaction.fromBEEF(beef); const txid = subjectTx.id('hex'); const parsedBeef = sdk_1.Beef.fromBinary(beef); const subject = parsedBeef.findTxid(txid); const subjectRawTxBytes = (_b = (_a = subject === null || subject === void 0 ? void 0 : subject.rawTx) === null || _a === void 0 ? void 0 : _a.length) !== null && _b !== void 0 ? _b : subjectTx.toBinary().length; const proofCount = parsedBeef.txs.filter(tx => tx.hasProof).length; const txsWithoutProof = parsedBeef.txs.filter(tx => !tx.hasProof && !tx.isTxidOnly).length; return { outputIndex, txid, beefBytes: beef.length, txCount: parsedBeef.txs.length, proofCount, txsWithoutProof, subjectHasProof: (_c = subject === null || subject === void 0 ? void 0 : subject.hasProof) !== null && _c !== void 0 ? _c : false, subjectRawTxBytes, currentToSubjectRawRatio: subjectRawTxBytes > 0 ? beef.length / subjectRawTxBytes : undefined, contextBytes: context === null || context === void 0 ? void 0 : context.length }; } catch (error) { return { outputIndex, beefBytes: beef.length, contextBytes: context === null || context === void 0 ? void 0 : context.length, error: error instanceof Error ? error.message : 'Failed to parse BEEF' }; } } function addOutputWarnings(warnings, output, thresholds) { if (output.beefBytes >= thresholds.beefBytes) { warnings.push({ code: 'beef-bytes', message: `Output BEEF is ${output.beefBytes} bytes`, value: output.beefBytes, threshold: thresholds.beefBytes, outputIndex: output.outputIndex, txid: output.txid }); } if (typeof output.txsWithoutProof === 'number' && output.txsWithoutProof >= thresholds.txsWithoutProof) { warnings.push({ code: 'txs-without-proof', message: `Output BEEF has ${output.txsWithoutProof} transactions without direct proof`, value: output.txsWithoutProof, threshold: thresholds.txsWithoutProof, outputIndex: output.outputIndex, txid: output.txid }); } if (thresholds.requireSubjectProof && output.subjectHasProof === false) { warnings.push({ code: 'subject-proof-missing', message: 'Output subject transaction does not have a direct Merkle proof', value: false, threshold: true, outputIndex: output.outputIndex, txid: output.txid }); } } function readNumberArray(value) { if (!Array.isArray(value)) return undefined; if (!value.every(item => Number.isInteger(item) && item >= 0 && item <= 255)) return undefined; return value; } //# sourceMappingURL=OverlayMonitor.js.map