UNPKG

@hashgraph/sdk

Version:
526 lines (458 loc) 15.1 kB
// SPDX-License-Identifier: Apache-2.0 import TransactionId from "../transaction/TransactionId.js"; import SubscriptionHandle from "./SubscriptionHandle.js"; import TopicMessage from "./TopicMessage.js"; import * as HieroProto from "@hashgraph/proto"; import TopicId from "./TopicId.js"; import Long from "long"; import Timestamp from "../Timestamp.js"; import { RST_STREAM } from "../Executable.js"; /** * @typedef {import("../channel/Channel.js").default} Channel * @typedef {import("../channel/MirrorChannel.js").default} MirrorChannel * @typedef {import("../channel/MirrorChannel.js").MirrorError} MirrorError */ /** * @template {Channel} ChannelT * @typedef {import("../client/Client.js").default<ChannelT, MirrorChannel>} Client<ChannelT, MirrorChannel> */ /** * Represents a class that you can use to subscribe to * different topics on Hedera network. * @augments {Query<TopicMessageQuery>} */ export default class TopicMessageQuery { /** * @param {object} props * @param {TopicId | string} [props.topicId] * @param {Timestamp} [props.startTime] * @param {Timestamp} [props.endTime] * @param {(message: TopicMessage | null, error: Error)=> void} [props.errorHandler] * @param {() => void} [props.completionHandler] * @param {(error: MirrorError | Error | null) => boolean} [props.retryHandler] * @param {Long | number} [props.limit] */ constructor(props = {}) { /** * @private * @type {?TopicId} */ this._topicId = null; if (props.topicId != null) { this.setTopicId(props.topicId); } /** * @private * @type {?Timestamp} */ this._startTime = null; if (props.startTime != null) { this.setStartTime(props.startTime); } /** * @private * @type {?Timestamp} */ this._endTime = null; if (props.endTime != null) { this.setEndTime(props.endTime); } /** * @private * @type {?Long} */ this._limit = null; if (props.limit != null) { this.setLimit(props.limit); } /** * @private * @type {(message: TopicMessage | null, error: Error) => void} */ // eslint-disable-next-line @typescript-eslint/no-unused-vars this._errorHandler = (message, error) => { console.error( `Error attempting to subscribe to topic: ${ this._topicId != null ? this._topicId.toString() : "" }`, ); }; if (props.errorHandler != null) { this._errorHandler = props.errorHandler; } /* * @private * @type {((message: TopicMessage) => void) | null} */ this._listener = null; /** * @private * @type {() => void} */ this._completionHandler = () => { console.log( `Subscription to topic ${ this._topicId != null ? this._topicId.toString() : "" } complete`, ); }; if (props.completionHandler != null) { this._completionHandler = props.completionHandler; } /* The number of times we can retry the grpc call * * @internal * @type {number} */ this._maxAttempts = 20; /** * This is the request's max backoff * * @internal * @type {number} */ this._maxBackoff = 8000; /** * @private * @type {(error: MirrorError | Error | null) => boolean} */ this._retryHandler = (error) => { if (error != null) { if (error instanceof Error) { // Retry on all errors which are not `MirrorError` because they're // likely lower level HTTP/2 errors return true; } else { // Retry on `NOT_FOUND`, `RESOURCE_EXHAUSTED`, `UNAVAILABLE`, and conditionally on `INTERNAL` // if the message matches the right regex. switch (error.code) { // INTERNAL // eslint-disable-next-line no-fallthrough case 13: return RST_STREAM.test(error.details.toString()); // NOT_FOUND // eslint-disable-next-line no-fallthrough case 5: // RESOURCE_EXHAUSTED // eslint-disable-next-line no-fallthrough case 8: // UNAVAILABLE // eslint-disable-next-line no-fallthrough case 14: case 17: return true; default: return false; } } } return false; }; if (props.retryHandler != null) { this._retryHandler = props.retryHandler; } /** * @private * @type {number} */ this._attempt = 0; /** * @private * @type {SubscriptionHandle | null} */ this._handle = null; this.setMaxBackoff(8000); } /** * @returns {?TopicId} */ get topicId() { return this._topicId; } /** * @param {TopicId | string} topicId * @returns {TopicMessageQuery} */ setTopicId(topicId) { this.requireNotSubscribed(); this._topicId = typeof topicId === "string" ? TopicId.fromString(topicId) : topicId.clone(); return this; } /** * @returns {?Timestamp} */ get startTime() { return this._startTime; } /** * @param {Timestamp | Date | number} startTime * @returns {TopicMessageQuery} */ setStartTime(startTime) { this.requireNotSubscribed(); this._startTime = startTime instanceof Timestamp ? startTime : startTime instanceof Date ? Timestamp.fromDate(startTime) : new Timestamp(startTime, 0); return this; } /** * @returns {?Timestamp} */ get endTime() { return this._endTime; } /** * @param {Timestamp | Date | number} endTime * @returns {TopicMessageQuery} */ setEndTime(endTime) { this.requireNotSubscribed(); this._endTime = endTime instanceof Timestamp ? endTime : endTime instanceof Date ? Timestamp.fromDate(endTime) : new Timestamp(endTime, 0); return this; } /** * @returns {?Long} */ get limit() { return this._limit; } /** * @param {Long | number} limit * @returns {TopicMessageQuery} */ setLimit(limit) { this.requireNotSubscribed(); this._limit = limit instanceof Long ? limit : Long.fromValue(limit); return this; } /** * @param {(message: TopicMessage | null, error: Error)=> void} errorHandler * @returns {TopicMessageQuery} */ setErrorHandler(errorHandler) { this._errorHandler = errorHandler; return this; } /** * @param {() => void} completionHandler * @returns {TopicMessageQuery} */ setCompletionHandler(completionHandler) { this.requireNotSubscribed(); this._completionHandler = completionHandler; return this; } /** * @param {number} attempts * @returns {this} */ setMaxAttempts(attempts) { this.requireNotSubscribed(); this._maxAttempts = attempts; return this; } /** * @param {number} backoff * @returns {this} */ setMaxBackoff(backoff) { this.requireNotSubscribed(); this._maxBackoff = backoff; return this; } /** * @param {Client<Channel>} client * @param {((message: TopicMessage | null, error: Error) => void) | null} errorHandler * @param {(message: TopicMessage) => void} listener * @returns {SubscriptionHandle} */ subscribe(client, errorHandler, listener) { this._handle = new SubscriptionHandle(); this._listener = listener; if (errorHandler != null) { this._errorHandler = errorHandler; } this._makeServerStreamRequest(client); return this._handle; } /** * Makes a server stream request to subscribe to topic messages * @private * @param {Client<Channel>} client * @returns {void} */ _makeServerStreamRequest(client) { const request = this._buildConsensusRequest(); /** @type {Map<string, HieroProto.com.hedera.mirror.api.proto.ConsensusTopicResponse[]>} */ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const list = new Map(); const streamHandler = client._mirrorNetwork .getNextMirrorNode() .getChannel() .makeServerStreamRequest( "ConsensusService", "subscribeTopic", request, (data) => this._handleMessage(data, list), (error) => this._handleError(error, client), this._completionHandler, ); if (this._handle != null) { this._handle._setCall(() => streamHandler()); } } requireNotSubscribed() { if (this._handle != null) { throw new Error( "Cannot change fields on an already subscribed query", ); } } /** * @private * @param {TopicMessage} topicMessage */ _passTopicMessage(topicMessage) { try { if (this._listener != null) { this._listener(topicMessage); } else { throw new Error("(BUG) listener is unexpectedly not set"); } } catch (error) { this._errorHandler(topicMessage, /** @type {Error} */ (error)); } } /** * Builds the consensus topic query request * @private * @returns {Uint8Array} Encoded consensus topic query */ _buildConsensusRequest() { return HieroProto.com.hedera.mirror.api.proto.ConsensusTopicQuery.encode( { topicID: this._topicId?._toProtobuf() ?? null, consensusStartTime: this._startTime?._toProtobuf() ?? null, consensusEndTime: this._endTime?._toProtobuf() ?? null, limit: this._limit, }, ).finish(); } /** * Handles an incoming message from the topic subscription * @private * @param {Uint8Array} data - Raw message data * @param {Map<string, HieroProto.com.hedera.mirror.api.proto.ConsensusTopicResponse[]>} list */ _handleMessage(data, list) { const message = HieroProto.com.hedera.mirror.api.proto.ConsensusTopicResponse.decode( data, ); if (this._limit?.gt(0)) { this._limit = this._limit.sub(1); } this._startTime = Timestamp._fromProtobuf( /** @type {HieroProto.proto.ITimestamp} */ ( message.consensusTimestamp ), ).plusNanos(1); if ( message.chunkInfo == null || (message.chunkInfo != null && message.chunkInfo.total === 1) ) { this._passTopicMessage(TopicMessage._ofSingle(message)); } else { this._handleChunkedMessage(message, list); } } /** * Handles a chunked message from the topic subscription * @private * @param {HieroProto.com.hedera.mirror.api.proto.ConsensusTopicResponse} message - The message response * @param {Map<string, HieroProto.com.hedera.mirror.api.proto.ConsensusTopicResponse[]>} list */ _handleChunkedMessage(message, list) { const chunkInfo = /** @type {HieroProto.proto.IConsensusMessageChunkInfo} */ ( message.chunkInfo ); const initialTransactionID = /** @type {HieroProto.proto.ITransactionID} */ ( chunkInfo.initialTransactionID ); const total = /** @type {number} */ (chunkInfo.total); const transactionId = TransactionId._fromProtobuf(initialTransactionID).toString(); /** @type {HieroProto.com.hedera.mirror.api.proto.ConsensusTopicResponse[]} */ let responses = []; const temp = list.get(transactionId); if (temp == null) { list.set(transactionId, responses); } else { responses = temp; } responses.push(message); if (responses.length === total) { const topicMessage = TopicMessage._ofMany(responses); list.delete(transactionId); this._passTopicMessage(topicMessage); } } /** * Handles errors from the topic subscription * @private * @param {MirrorError | Error} error - The error that occurred * @param {Client<Channel>} client - The client to use for retries * @returns {void} */ _handleError(error, client) { const message = error instanceof Error ? error.message : error.details; if (this._handle?._unsubscribed) { return; } if (this.shouldRetry(error)) { this._scheduleRetry(client, message); } else { this._errorHandler(null, new Error(message)); } } /** * Determines if a retry should be attempted * @private * @param {MirrorError | Error} error - The error to check * @returns {boolean} - Whether to retry */ shouldRetry(error) { return this._attempt < this._maxAttempts && this._retryHandler(error); } /** * Schedules a retry of the server stream request * @private * @param {Client<Channel>} client - The client to use for the retry * @param {string} errorMessage - The error message for logging * @returns {void} */ _scheduleRetry(client, errorMessage) { const delay = Math.min(250 * 2 ** this._attempt, this._maxBackoff); console.warn( `Error subscribing to topic ${ this._topicId?.toString() ?? "UNKNOWN" } ` + `during attempt ${this._attempt}. Waiting ${delay} ms before next attempt: ${errorMessage}`, ); this._attempt += 1; setTimeout(() => this._makeServerStreamRequest(client), delay); } }