@@ -26,7 +26,8 @@ import {
2626 hydraUTxO ,
2727 hydraUTxOs ,
2828 ServerOutput ,
29- } from "./types" ;
29+ ConnectionState ,
30+ } from "." ;
3031import { handleHydraErrors } from "./types/events/handler" ;
3132import {
3233 PostTxOnChainFailed ,
@@ -52,6 +53,11 @@ export class HydraProvider implements IFetcher, ISubmitter {
5253 | ( ( data : ServerOutput | ClientMessage ) => void )
5354 | null = null ;
5455 private _messageQueue : ( ServerOutput | ClientMessage ) [ ] = [ ] ;
56+ private _disconnectTimeout : NodeJS . Timeout | null = null ;
57+ private _isDisconnecting : boolean = false ;
58+ private _currentStatus : hydraStatus = "IDLE" ;
59+ private _connectionState : ConnectionState = "IDLE" ;
60+ private _connectingPromise : Promise < boolean > | null = null ;
5561
5662 constructor ( {
5763 httpUrl,
@@ -86,27 +92,134 @@ export class HydraProvider implements IFetcher, ISubmitter {
8692 }
8793 } ,
8894 ) ;
95+
96+ this . _eventEmitter . on ( "onstatuschange" , ( status : hydraStatus ) => {
97+ this . _currentStatus = status ;
98+ } ) ;
8999 }
90100
91101 /**
92- * Connects to the Hydra Head.
93- */
94- async connect ( ) {
95- this . _connection . connect ( ) ;
102+ * Connects to the Hydra Head socket only.
103+ */
104+ async connect ( ) : Promise < void > {
105+ try {
106+ await this . _connection . connect ( ) ;
107+ } catch ( error ) {
108+ throw new Error (
109+ `Failed to connect to Hydra Head: ${ error instanceof Error ? error . message : String ( error )
110+ } `,
111+ ) ;
112+ }
113+ }
114+ /**
115+ * Connects (if needed) and waits until Hydra confirms readiness via "Greetings".
116+ *
117+ * - Idempotent
118+ * - No dangling handlers
119+ * - Accurate state tracking
120+ */
121+ async isConnected ( timeoutMs = 30_000 ) : Promise < boolean > {
122+ // Fast path
123+ if ( this . _connectionState === "CONNECTED" ) {
124+ return true ;
125+ }
126+
127+ // Reuse in-flight connection
128+ if ( this . _connectingPromise ) {
129+ return this . _connectingPromise ;
130+ }
131+
132+ this . _connectingPromise = new Promise < boolean > ( async ( resolve , reject ) => {
133+ let timeout : NodeJS . Timeout ;
134+
135+ const finalize = ( state : ConnectionState , result ?: boolean , error ?: Error ) => {
136+ clearTimeout ( timeout ) ;
137+ this . _connectingPromise = null ;
138+ this . _connectionState = state ;
139+
140+ if ( error ) reject ( error ) ;
141+ else resolve ( result ?? false ) ;
142+ } ;
143+
144+ try {
145+ await this . connect ( ) ;
146+ } catch ( err ) {
147+ finalize ( "FAILED" , false , err as Error ) ;
148+ return ;
149+ }
150+
151+ timeout = setTimeout ( ( ) => {
152+ finalize (
153+ "FAILED" ,
154+ false ,
155+ new Error ( "Connection timed out: no Greetings from Hydra node" ) ,
156+ ) ;
157+ } , timeoutMs ) ;
158+
159+ this . onMessage ( ( msg ) => {
160+ if ( this . _connectionState !== "CONNECTING" ) return ;
161+
162+ if ( msg . tag === "Greetings" ) {
163+ finalize ( "CONNECTED" , true ) ;
164+ return ;
165+ }
166+
167+ if ( handleHydraErrors ( msg as ClientMessage , ( err ) => {
168+ finalize ( "FAILED" , false , err ) ;
169+ } ) ) {
170+ return ;
171+ }
172+ } ) ;
173+ } ) ;
174+
175+ return this . _connectingPromise ;
96176 }
97177
178+
98179 /**
99180 * Disconnects from the Hydra Head.
100181 *
101182 * @param timeout Optional timeout in milliseconds (defaults to 5 minutes) to wait for the disconnect operation to complete.
183+ * If set to 0, disconnects immediately (reactive to clicks/events).
102184 * If not provided, the default disconnect timeout will be used.
103185 * Useful for customizing how long to wait before disconnecting.
186+ * @throws {Error } If timeout is less than 0 or between 1 and 59,999 ms
104187 */
105188 async disconnect ( timeout : number = 300_000 ) {
106- if ( timeout < 60_000 ) {
107- throw new Error ( "Timeout must be at least 60,000 ms (1 minute)" ) ;
189+ if ( timeout < 0 ) {
190+ throw new Error ( "Timeout must be a non-negative number" ) ;
191+ }
192+ if ( timeout > 0 && timeout < 60_000 ) {
193+ throw new Error (
194+ "Timeout must be at least 60,000 ms (1 minute) or 0 for immediate disconnect" ,
195+ ) ;
196+ }
197+
198+ const clearPendingTimeout = ( ) => {
199+ if ( this . _disconnectTimeout ) {
200+ clearTimeout ( this . _disconnectTimeout ) ;
201+ this . _disconnectTimeout = null ;
202+ }
203+ } ;
204+
205+ if ( timeout === 0 ) {
206+ clearPendingTimeout ( ) ;
207+ this . _isDisconnecting = false ;
208+ await this . _connection . disconnect ( 0 ) ;
209+ return ;
108210 }
109- await this . _connection . disconnect ( timeout ) ;
211+
212+ if ( this . _isDisconnecting ) return ;
213+
214+ this . _isDisconnecting = true ;
215+ this . _disconnectTimeout = setTimeout ( async ( ) => {
216+ try {
217+ await this . _connection . disconnect ( 0 ) ;
218+ } finally {
219+ this . _disconnectTimeout = null ;
220+ this . _isDisconnecting = false ;
221+ }
222+ } , timeout ) ;
110223 }
111224
112225 /**
@@ -139,6 +252,8 @@ export class HydraProvider implements IFetcher, ISubmitter {
139252 }
140253 if ( handleHydraErrors ( msg as ClientMessage , reject ) ) {
141254 return ;
255+ } else {
256+ reject ( new Error ( "Failed to initialize, head is not in Idle state" ) ) ;
142257 }
143258 } ) ;
144259 } ) ;
@@ -161,6 +276,10 @@ export class HydraProvider implements IFetcher, ISubmitter {
161276 }
162277 if ( handleHydraErrors ( msg as ClientMessage , reject ) ) {
163278 return ;
279+ } else {
280+ reject (
281+ new Error ( "Failed to abort, head is not in Initializing state" ) ,
282+ ) ;
164283 }
165284 } ) ;
166285 } ) ;
@@ -203,6 +322,12 @@ export class HydraProvider implements IFetcher, ISubmitter {
203322 `Transaction invalid: ${ JSON . stringify ( msg . validationError ) } ` ,
204323 ) ,
205324 ) ;
325+ } else {
326+ reject (
327+ new Error (
328+ "Failed to submit transaction, head is not in Open state" ,
329+ ) ,
330+ ) ;
206331 }
207332 } ) ;
208333 } ) ;
@@ -233,6 +358,12 @@ export class HydraProvider implements IFetcher, ISubmitter {
233358 }
234359 if ( handleHydraErrors ( msg as ClientMessage , reject ) ) {
235360 return ;
361+ } else {
362+ reject (
363+ new Error (
364+ "Failed to recover transaction, head is not in Open state" ,
365+ ) ,
366+ ) ;
236367 }
237368 } ) ;
238369 } ) ;
@@ -270,6 +401,8 @@ export class HydraProvider implements IFetcher, ISubmitter {
270401 }
271402 if ( handleHydraErrors ( msg as ClientMessage , reject ) ) {
272403 return ;
404+ } else {
405+ reject ( new Error ( "Failed to decommit, head is not in Open state" ) ) ;
273406 }
274407 } ) ;
275408 } ) ;
@@ -303,6 +436,8 @@ export class HydraProvider implements IFetcher, ISubmitter {
303436 if ( handleHydraErrors ( message as ClientMessage , reject ) ) {
304437 reject ( new Error ( "Failed to close head" ) ) ;
305438 return ;
439+ } else {
440+ reject ( new Error ( "Failed to close, head is not in Open state" ) ) ;
306441 }
307442 } ) ;
308443 } ) ;
@@ -325,6 +460,8 @@ export class HydraProvider implements IFetcher, ISubmitter {
325460 if ( handleHydraErrors ( msg as ClientMessage , reject ) ) {
326461 reject ( new Error ( "Failed to contest head" ) ) ;
327462 return ;
463+ } else {
464+ reject ( new Error ( "Failed to contest, head is not in Closed state" ) ) ;
328465 }
329466 } ) ;
330467 } ) ;
@@ -349,6 +486,8 @@ export class HydraProvider implements IFetcher, ISubmitter {
349486 if ( handleHydraErrors ( msg as ClientMessage , reject ) ) {
350487 reject ( new Error ( "Failed to fanout head" ) ) ;
351488 return ;
489+ } else {
490+ reject ( new Error ( "Failed to fanout, head is not in Closed state" ) ) ;
352491 }
353492 } ) ;
354493 } ) ;
@@ -738,16 +877,62 @@ export class HydraProvider implements IFetcher, ISubmitter {
738877 return protocolParams ;
739878 }
740879
880+ /**
881+ * Registers a callback to receive messages from the Hydra Head.
882+ * When called, the callback will immediately be invoked for all messages that have already been received
883+ * (queued in the message queue), and subsequently for each new incoming message.
884+ *
885+ * @param callback - The function to call with each ServerOutput or ClientMessage received.
886+ *
887+ * @example
888+ * ```ts
889+ * hydraProvider.onMessage((message) => {
890+ * console.log("Received Hydra message:", message);
891+ * });
892+ * ```
893+ */
741894 onMessage ( callback : ( data : ServerOutput | ClientMessage ) => void ) {
742895 this . _messageCallback = callback ;
743896 this . _messageQueue . forEach ( ( message ) => {
744897 callback ( message ) ;
745898 } ) ;
746899 }
747- onStatusChange ( callback : ( status : hydraStatus ) => void ) {
748- this . _eventEmitter . on ( "onstatuschange" , ( status ) => {
900+
901+ /**
902+ * Subscribe to status changes of the Hydra Head.
903+ * The callback will be called whenever the status changes.
904+ *
905+ * @param callback Function to call when status changes, receives the new hydraStatus
906+ * @returns The current status
907+ *
908+ * @example
909+ * ```ts
910+ * hydraProvider.onStatusChange((status) => {
911+ * console.log(`Hydra Head status changed to: ${status}`);
912+ * });
913+ * ```
914+ */
915+ onStatusChange ( callback : ( status : hydraStatus ) => void ) : hydraStatus {
916+ this . _eventEmitter . on ( "onstatuschange" , ( status : hydraStatus ) => {
917+ this . _currentStatus = status ;
749918 callback ( status ) ;
750919 } ) ;
920+ return this . _currentStatus ;
921+ }
922+
923+ /**
924+ * Get the current status of the Hydra Head.
925+ *
926+ * @returns The current hydraStatus
927+ *
928+ * @example
929+ * ```ts
930+ * const currentStatus = hydraProvider.getStatus();
931+ * console.log(`Current status: ${currentStatus}`);
932+ * ```
933+ */
934+ getStatus ( ) : hydraStatus {
935+ return this . _currentStatus ;
751936 }
752937
753938 /**
0 commit comments