@bsv/overlay-express
Version:
BSV Blockchain Overlay Express
193 lines • 7.12 kB
JavaScript
;
/**
* Consumes the go-chaintracks (Arcade) reorg SSE stream (`GET /v2/reorg/stream`)
* and normalizes each `ReorgEvent` into the overlay engine's `handleReorg` input.
*
* The stream emits `data: <JSON>\n\n` frames (no `event:`/`id:` lines) with
* `: keepalive` comment frames in between. Because there are no event ids, a
* reconnect cannot replay events missed while disconnected — so every (re)connect
* triggers an `onConnect` catch-up (the engine's revalidation sweep).
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.ReorgSseAdapter = void 0;
exports.parseReorgEvent = parseReorgEvent;
exports.extractSseFrames = extractSseFrames;
/**
* Parses a single SSE data frame (a go-chaintracks `ReorgEvent` JSON document)
* into `handleReorg` input. Returns `null` for malformed frames.
*
* `ReorgEvent` = { orphanedHashes: string[], commonAncestor: BlockHeader|null,
* newTip: BlockHeader, depth: number }. Block hashes are go-sdk `chainhash.Hash`
* values marshaled as reversed (display) hex; they are lower-cased here to match
* the overlay's stored block hashes.
*/
function parseReorgEvent(frame) {
var _a, _b;
let event;
try {
event = JSON.parse(frame);
}
catch {
return null;
}
const newTipHeight = (_a = event === null || event === void 0 ? void 0 : event.newTip) === null || _a === void 0 ? void 0 : _a.height;
if (typeof newTipHeight !== 'number') {
return null;
}
const orphanedBlockHashes = Array.isArray(event.orphanedHashes)
? event.orphanedHashes
.filter((hash) => typeof hash === 'string')
.map((hash) => hash.toLowerCase())
: [];
const ancestorHeight = (_b = event === null || event === void 0 ? void 0 : event.commonAncestor) === null || _b === void 0 ? void 0 : _b.height;
const depth = event === null || event === void 0 ? void 0 : event.depth;
let rebuildFromHeight;
if (typeof ancestorHeight === 'number') {
rebuildFromHeight = ancestorHeight + 1;
}
else if (typeof depth === 'number') {
// No common ancestor (e.g. reorg deeper than retained history): fall back to
// the reported depth from the new tip.
rebuildFromHeight = Math.max(0, newTipHeight - depth + 1);
}
else {
return null;
}
return { orphanedBlockHashes, rebuildFromHeight, newTipHeight };
}
/**
* Splits an SSE byte buffer into complete event payloads. Frames are delimited by
* a blank line (`\n\n`); within a frame, `data:` lines are concatenated and
* comment lines (starting with `:`) are ignored. Any trailing partial frame is
* returned in `rest` for the next read.
*/
function extractSseFrames(buffer) {
const events = [];
let working = buffer;
let boundary = working.indexOf('\n\n');
while (boundary !== -1) {
const block = working.slice(0, boundary);
working = working.slice(boundary + 2);
const dataLines = [];
for (const line of block.split('\n')) {
if (line.startsWith(':')) {
continue;
}
if (line.startsWith('data:')) {
dataLines.push(line.slice(5).replace(/^ /, ''));
}
}
if (dataLines.length > 0) {
events.push(dataLines.join(''));
}
boundary = working.indexOf('\n\n');
}
return { events, rest: working };
}
/**
* Long-lived SSE client that keeps the overlay's BASM anchors reconciled with
* chain reorgs. Auto-reconnects with a fixed delay and runs a catch-up on connect.
*/
class ReorgSseAdapter {
constructor(options) {
var _a, _b, _c;
this.stopped = false;
this.url = options.url;
this.onReorg = options.onReorg;
this.onConnect = options.onConnect;
this.logger = (_a = options.logger) !== null && _a !== void 0 ? _a : console;
this.reconnectDelayMs = (_b = options.reconnectDelayMs) !== null && _b !== void 0 ? _b : 5000;
this.fetchImpl = (_c = options.fetchImpl) !== null && _c !== void 0 ? _c : fetch;
}
/** Begins consuming the stream in the background. */
start() {
if (this.stopped) {
return;
}
void this.runLoop();
}
/** Stops consuming and aborts any in-flight connection. */
stop() {
var _a;
this.stopped = true;
(_a = this.controller) === null || _a === void 0 ? void 0 : _a.abort();
}
async runLoop() {
while (!this.stopped) {
try {
await this.connectOnce();
}
catch (error) {
if (!this.stopped) {
this.logger.warn(`[BASM] reorg stream error: ${error instanceof Error ? error.message : String(error)}`);
}
}
if (this.stopped) {
break;
}
await this.delay(this.reconnectDelayMs);
}
}
async connectOnce() {
this.controller = new AbortController();
const response = await this.fetchImpl(this.url, {
headers: { Accept: 'text/event-stream' },
signal: this.controller.signal
});
if (!response.ok || response.body == null) {
throw new Error(`reorg stream responded ${response.status}`);
}
// No event-id replay on this stream: catch up on (re)connect.
await this.runCatchUp();
await this.pumpEvents(response.body.getReader());
}
async runCatchUp() {
if (this.onConnect === undefined) {
return;
}
try {
await this.onConnect();
}
catch (error) {
this.logger.warn(`[BASM] reorg catch-up failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
async pumpEvents(reader) {
const decoder = new TextDecoder();
let buffer = '';
while (!this.stopped) {
const { value, done } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const { events, rest } = extractSseFrames(buffer);
buffer = rest;
for (const frame of events) {
await this.processReorgFrame(frame);
}
}
}
async processReorgFrame(frame) {
const input = parseReorgEvent(frame);
if (input === null) {
this.logger.warn('[BASM] skipping malformed reorg frame');
return;
}
try {
await this.onReorg(input);
}
catch (error) {
this.logger.error(`[BASM] handleReorg failed: ${error instanceof Error ? error.message : String(error)}`);
}
}
async delay(ms) {
await new Promise(resolve => {
var _a;
const timer = setTimeout(resolve, ms);
(_a = timer.unref) === null || _a === void 0 ? void 0 : _a.call(timer);
});
}
}
exports.ReorgSseAdapter = ReorgSseAdapter;
//# sourceMappingURL=ReorgStream.js.map