UNPKG

@bsv/overlay-express

Version:
193 lines 7.12 kB
"use strict"; /** * 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