Changeset View
Changeset View
Standalone View
Standalone View
modules/chronik-client/src/ChronikClient.ts
// Copyright (c) 2023-2024 The Bitcoin developers | // Copyright (c) 2023-2024 The Bitcoin developers | ||||
// Distributed under the MIT software license, see the accompanying | // Distributed under the MIT software license, see the accompanying | ||||
// file COPYING or http://www.opensource.org/licenses/mit-license.php. | // file COPYING or http://www.opensource.org/licenses/mit-license.php. | ||||
import cashaddr from 'ecashaddrjs'; | import cashaddr from 'ecashaddrjs'; | ||||
import WebSocket from 'isomorphic-ws'; | import WebSocket from 'isomorphic-ws'; | ||||
import * as ws from 'ws'; | import * as ws from 'ws'; | ||||
import * as proto from '../proto/chronik'; | import * as proto from '../proto/chronik'; | ||||
import { FailoverProxy } from './failoverProxy'; | import { FailoverProxy } from './failoverProxy'; | ||||
import { fromHex, toHex, toHexRev } from './hex'; | import { fromHex, toHex, toHexRev } from './hex'; | ||||
import { | import { | ||||
isValidWsSubscription, | isValidWsSubscription, | ||||
verifyLokadId, | verifyLokadId, | ||||
verifyTokenId, | verifyTokenId, | ||||
verifyPluginSubscription, | |||||
} from './validation'; | } from './validation'; | ||||
type MessageEvent = ws.MessageEvent | { data: Blob }; | type MessageEvent = ws.MessageEvent | { data: Blob }; | ||||
/** | /** | ||||
* Client to access an in-node Chronik instance. | * Client to access an in-node Chronik instance. | ||||
* Plain object, without any connections. | * Plain object, without any connections. | ||||
*/ | */ | ||||
▲ Show 20 Lines • Show All 536 Lines • ▼ Show 20 Lines | export class WsEndpoint { | ||||
constructor(proxyInterface: FailoverProxy, config: WsConfig) { | constructor(proxyInterface: FailoverProxy, config: WsConfig) { | ||||
this.onMessage = config.onMessage; | this.onMessage = config.onMessage; | ||||
this.onConnect = config.onConnect; | this.onConnect = config.onConnect; | ||||
this.onReconnect = config.onReconnect; | this.onReconnect = config.onReconnect; | ||||
this.onEnd = config.onEnd; | this.onEnd = config.onEnd; | ||||
this.autoReconnect = | this.autoReconnect = | ||||
config.autoReconnect !== undefined ? config.autoReconnect : true; | config.autoReconnect !== undefined ? config.autoReconnect : true; | ||||
this.manuallyClosed = false; | this.manuallyClosed = false; | ||||
this.subs = { scripts: [], tokens: [], lokadIds: [], blocks: false }; | this.subs = { | ||||
scripts: [], | |||||
tokens: [], | |||||
lokadIds: [], | |||||
plugins: [], | |||||
blocks: false, | |||||
}; | |||||
this._proxyInterface = proxyInterface; | this._proxyInterface = proxyInterface; | ||||
} | } | ||||
/** Wait for the WebSocket to be connected. */ | /** Wait for the WebSocket to be connected. */ | ||||
public async waitForOpen() { | public async waitForOpen() { | ||||
await this._proxyInterface.connectWs(this); | await this._proxyInterface.connectWs(this); | ||||
await this.connected; | await this.connected; | ||||
} | } | ||||
▲ Show 20 Lines • Show All 154 Lines • ▼ Show 20 Lines | public unsubscribeFromTokenId(tokenId: string) { | ||||
this.subs.tokens.splice(unsubIndex, 1); | this.subs.tokens.splice(unsubIndex, 1); | ||||
if (this.ws?.readyState === WebSocket.OPEN) { | if (this.ws?.readyState === WebSocket.OPEN) { | ||||
// Send unsubscribe msg to chronik server | // Send unsubscribe msg to chronik server | ||||
this._subUnsubToken(true, tokenId); | this._subUnsubToken(true, tokenId); | ||||
} | } | ||||
} | } | ||||
/** Subscribe to a plugin */ | |||||
public subscribeToPlugin(pluginName: string, group: string) { | |||||
// Build sub according to chronik expected type | |||||
const subscription: WsSubPluginClient = { | |||||
pluginName, | |||||
group, | |||||
}; | |||||
verifyPluginSubscription(subscription); | |||||
// Update ws.subs to include this plugin | |||||
this.subs.plugins.push(subscription); | |||||
if (this.ws?.readyState === WebSocket.OPEN) { | |||||
// Send subscribe msg to chronik server | |||||
this._subUnsubPlugin(false, subscription); | |||||
} | |||||
} | |||||
/** Unsubscribe from the given plugin */ | |||||
public unsubscribeFromPlugin(pluginName: string, group: string) { | |||||
tobias_ruck: eventually we should abstract this for all subscription types but not in this diff | |||||
// Find the requested unsub script and remove it | |||||
const unsubIndex = this.subs.plugins.findIndex( | |||||
sub => sub.pluginName === pluginName && sub.group === group, | |||||
); | |||||
if (unsubIndex === -1) { | |||||
// If we cannot find this subscription in this.subs.plugins, throw an error | |||||
// We do not want an app developer thinking they have unsubscribed from something | |||||
throw new Error( | |||||
`No existing sub at pluginName="${pluginName}", group="${group}"`, | |||||
); | |||||
} | |||||
// Remove the requested subscription from this.subs.plugins | |||||
this.subs.plugins.splice(unsubIndex, 1); | |||||
if (this.ws?.readyState === WebSocket.OPEN) { | |||||
// Send unsubscribe msg to chronik server | |||||
this._subUnsubPlugin(true, { | |||||
pluginName, | |||||
group, | |||||
}); | |||||
} | |||||
} | |||||
/** | /** | ||||
* Close the WebSocket connection and prevent any future reconnection | * Close the WebSocket connection and prevent any future reconnection | ||||
* attempts. | * attempts. | ||||
*/ | */ | ||||
public close() { | public close() { | ||||
this.manuallyClosed = true; | this.manuallyClosed = true; | ||||
this.ws?.close(); | this.ws?.close(); | ||||
} | } | ||||
▲ Show 20 Lines • Show All 55 Lines • ▼ Show 20 Lines | private _subUnsubToken(isUnsub: boolean, tokenId: string) { | ||||
if (this.ws === undefined) { | if (this.ws === undefined) { | ||||
throw new Error('Invalid state; _ws is undefined'); | throw new Error('Invalid state; _ws is undefined'); | ||||
} | } | ||||
this.ws.send(encodedSubscription); | this.ws.send(encodedSubscription); | ||||
} | } | ||||
private _subUnsubPlugin(isUnsub: boolean, plugin: WsSubPluginClient) { | |||||
const encodedSubscription = proto.WsSub.encode({ | |||||
isUnsub, | |||||
plugin: { | |||||
pluginName: plugin.pluginName, | |||||
// User input for plugin group is string | |||||
// Chronik expects bytes | |||||
group: fromHex(plugin.group), | |||||
}, | |||||
}).finish(); | |||||
if (this.ws === undefined) { | |||||
throw new Error('Invalid state; _ws is undefined'); | |||||
} | |||||
this.ws.send(encodedSubscription); | |||||
} | |||||
public async handleMsg(wsMsg: MessageEvent) { | public async handleMsg(wsMsg: MessageEvent) { | ||||
if (typeof this.onMessage === 'undefined') { | if (typeof this.onMessage === 'undefined') { | ||||
return; | return; | ||||
} | } | ||||
const data = | const data = | ||||
typeof window === 'undefined' | typeof window === 'undefined' | ||||
? // NodeJS | ? // NodeJS | ||||
(wsMsg.data as Uint8Array) | (wsMsg.data as Uint8Array) | ||||
▲ Show 20 Lines • Show All 939 Lines • ▼ Show 20 Lines | export interface WsSubScriptClient { | ||||
* Payload for the given script type: | * Payload for the given script type: | ||||
* - 20-byte hash for "p2pkh" and "p2sh" | * - 20-byte hash for "p2pkh" and "p2sh" | ||||
* - 33-byte or 65-byte pubkey for "p2pk" | * - 33-byte or 65-byte pubkey for "p2pk" | ||||
* - Serialized script for "other" | * - Serialized script for "other" | ||||
*/ | */ | ||||
payload: string; | payload: string; | ||||
} | } | ||||
/* The plugin name and its group for a chronik-client subscribeToPlugin subscription */ | |||||
export interface WsSubPluginClient { | |||||
/** pluginName as lower-case hex string */ | |||||
pluginName: string; | |||||
/** group as lower-case hex string */ | |||||
group: string; | |||||
} | |||||
export interface Error { | export interface Error { | ||||
type: 'Error'; | type: 'Error'; | ||||
msg: string; | msg: string; | ||||
} | } | ||||
/** List of UTXOs */ | /** List of UTXOs */ | ||||
export interface TokenIdUtxos { | export interface TokenIdUtxos { | ||||
/** TokenId used to fetch these utxos */ | /** TokenId used to fetch these utxos */ | ||||
▲ Show 20 Lines • Show All 66 Lines • ▼ Show 20 Lines | |||||
interface WsSubscriptions { | interface WsSubscriptions { | ||||
/** Subscriptions to scripts */ | /** Subscriptions to scripts */ | ||||
scripts: WsSubScriptClient[]; | scripts: WsSubScriptClient[]; | ||||
/** Subscriptions to tokens by tokenId */ | /** Subscriptions to tokens by tokenId */ | ||||
tokens: string[]; | tokens: string[]; | ||||
/** Subscriptions to lokadIds */ | /** Subscriptions to lokadIds */ | ||||
lokadIds: string[]; | lokadIds: string[]; | ||||
/** Subscriptions to plugins */ | |||||
plugins: WsSubPluginClient[]; | |||||
/** Subscription to blocks */ | /** Subscription to blocks */ | ||||
blocks: boolean; | blocks: boolean; | ||||
} | } |
eventually we should abstract this for all subscription types but not in this diff