@bsv/overlay-express
Version:
BSV Blockchain Overlay Express
578 lines • 23.5 kB
JavaScript
;
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