@@ -126,6 +126,17 @@ export interface AblyCliTerminalProperties {
126126 * dev) to exercise the pause/resume cycle without waiting.
127127 */
128128 inactivityTimeoutMs ?: number ;
129+ /**
130+ * Called right before each (re)connection handshake to obtain fresh auth.
131+ * Short-lived signed configs expire (the terminal server rejects configs
132+ * older than a few minutes), so a resume or reconnect after the tab has been
133+ * idle would otherwise fail with "Config expired". Return fresh credentials to
134+ * use for the handshake, or null/undefined to keep the current
135+ * signedConfig/signature props. Errors are swallowed and fall back to props.
136+ */
137+ refreshCredentials ?: ( ) => Promise <
138+ { signedConfig : string ; signature : string } | null | undefined
139+ > ;
129140 /**
130141 * When true, enables split-screen mode with a second independent terminal.
131142 * A split icon will be displayed in the top-right corner when in single-pane mode.
@@ -184,6 +195,7 @@ const AblyCliTerminalInner = (
184195 resumeOnReload,
185196 maxReconnectAttempts,
186197 inactivityTimeoutMs,
198+ refreshCredentials,
187199 enableSplitScreen = false ,
188200 showSplitControl = true ,
189201 } : AblyCliTerminalProperties ,
@@ -560,6 +572,41 @@ const AblyCliTerminalInner = (
560572 // Set while an inactivity-resume attempt is in flight, so a failed resume can
561573 // fall back to a fresh session instead of dead-ending on the manual prompt.
562574 const resumeAttemptReference = useRef < boolean > ( false ) ;
575+
576+ // Effective auth used for handshakes. Starts from the props and is replaced by
577+ // refreshCredentials() output (when provided) so resumes/reconnects after the
578+ // tab has been idle use a fresh, non-expired signed config.
579+ const refreshCredentialsReference = useRef ( refreshCredentials ) ;
580+ const effectiveSignedConfigReference = useRef ( signedConfig ) ;
581+ const effectiveSignatureReference = useRef ( signature ) ;
582+ useEffect ( ( ) => {
583+ refreshCredentialsReference . current = refreshCredentials ;
584+ } , [ refreshCredentials ] ) ;
585+ useEffect ( ( ) => {
586+ effectiveSignedConfigReference . current = signedConfig ;
587+ } , [ signedConfig ] ) ;
588+ useEffect ( ( ) => {
589+ effectiveSignatureReference . current = signature ;
590+ } , [ signature ] ) ;
591+
592+ // Pull fresh credentials before a handshake; falls back to current props.
593+ const refreshAuth = useCallback ( async ( ) => {
594+ const fn = refreshCredentialsReference . current ;
595+ if ( ! fn ) return ;
596+ try {
597+ const fresh = await fn ( ) ;
598+ if ( fresh ?. signedConfig && fresh ?. signature ) {
599+ effectiveSignedConfigReference . current = fresh . signedConfig ;
600+ effectiveSignatureReference . current = fresh . signature ;
601+ }
602+ } catch ( error ) {
603+ debugLog (
604+ "[AblyCLITerminal] refreshCredentials failed; using existing auth" ,
605+ error ,
606+ ) ;
607+ }
608+ } , [ ] ) ;
609+
563610 // Guard to ensure we do NOT double-count a failed attempt when both the
564611 // `error` and the subsequent `close` events fire for the *same* socket.
565612 const reconnectScheduledThisCycleReference = useRef < boolean > ( false ) ;
@@ -1435,7 +1482,7 @@ const AblyCliTerminalInner = (
14351482
14361483 const socketReference = useRef < WebSocket | null > ( null ) ; // Ref to hold the current socket for cleanup
14371484
1438- const handleWebSocketOpen = useCallback ( ( ) => {
1485+ const handleWebSocketOpen = useCallback ( async ( ) => {
14391486 // console.log('[AblyCLITerminal] WebSocket opened');
14401487 // Clear connection timeout since we successfully connected
14411488 clearConnectionTimeout ( ) ;
@@ -1473,8 +1520,16 @@ const AblyCliTerminalInner = (
14731520 // Don't send the initial command yet - wait for prompt detection
14741521 }
14751522
1523+ // Refresh credentials before authenticating so a resume/reconnect after the
1524+ // tab has been idle doesn't hand the server an expired signed config.
1525+ await refreshAuth ( ) ;
1526+
14761527 // Send auth payload with signed config
1477- const payload = createAuthPayload ( sessionId , signedConfig , signature ) ;
1528+ const payload = createAuthPayload (
1529+ sessionId ,
1530+ effectiveSignedConfigReference . current ,
1531+ effectiveSignatureReference . current ,
1532+ ) ;
14781533
14791534 debugLog (
14801535 `⚠️ DIAGNOSTIC: Preparing to send auth payload with env vars: ${ JSON . stringify ( payload . environmentVariables ) } ` ,
@@ -1513,8 +1568,7 @@ const AblyCliTerminalInner = (
15131568 clearPtyBuffer ,
15141569 sessionId ,
15151570 clearConnectionTimeout ,
1516- signedConfig ,
1517- signature ,
1571+ refreshAuth ,
15181572 ] ) ;
15191573
15201574 const handleWebSocketMessage = useCallback (
@@ -2520,7 +2574,10 @@ const AblyCliTerminalInner = (
25202574 const messageListener = ( event : MessageEvent ) => {
25212575 void handleWebSocketMessage ( event ) ;
25222576 } ;
2523- socket . addEventListener ( "open" , handleWebSocketOpen ) ;
2577+ const openListener = ( ) => {
2578+ void handleWebSocketOpen ( ) ;
2579+ } ;
2580+ socket . addEventListener ( "open" , openListener ) ;
25242581 socket . addEventListener ( "message" , messageListener ) ;
25252582 socket . addEventListener ( "close" , handleWebSocketClose ) ;
25262583 socket . addEventListener ( "error" , handleWebSocketError ) ;
@@ -2530,7 +2587,7 @@ const AblyCliTerminalInner = (
25302587 debugLog (
25312588 "[AblyCLITerminal] Cleaning up WebSocket event listeners for old socket." ,
25322589 ) ;
2533- socket . removeEventListener ( "open" , handleWebSocketOpen ) ;
2590+ socket . removeEventListener ( "open" , openListener ) ;
25342591 socket . removeEventListener ( "message" , messageListener ) ;
25352592 socket . removeEventListener ( "close" , handleWebSocketClose ) ;
25362593 socket . removeEventListener ( "error" , handleWebSocketError ) ;
@@ -3001,39 +3058,44 @@ const AblyCliTerminalInner = (
30013058
30023059 // WebSocket open handler
30033060 newSocket . addEventListener ( "open" , ( ) => {
3004- debugLog ( "[AblyCLITerminal] Secondary WebSocket opened" ) ;
3005-
3006- // Clear any reconnect prompt
3007- setSecondaryShowManualReconnectPrompt ( false ) ;
3008- secondaryShowManualReconnectPromptReference . current = false ;
3061+ void ( async ( ) => {
3062+ debugLog ( "[AblyCLITerminal] Secondary WebSocket opened" ) ;
30093063
3010- if ( secondaryTerm . current ) {
3011- secondaryTerm . current . focus ( ) ;
3012- }
3064+ // Clear any reconnect prompt
3065+ setSecondaryShowManualReconnectPrompt ( false ) ;
3066+ secondaryShowManualReconnectPromptReference . current = false ;
30133067
3014- // Send auth payload with signed config
3015- const payload = createAuthPayload (
3016- secondarySessionId ,
3017- signedConfig ,
3018- signature ,
3019- ) ;
3068+ if ( secondaryTerm . current ) {
3069+ secondaryTerm . current . focus ( ) ;
3070+ }
30203071
3021- if ( newSocket . readyState === WebSocket . OPEN ) {
3022- newSocket . send ( JSON . stringify ( payload ) ) ;
3023- }
3072+ // Refresh credentials before authenticating (see primary handler).
3073+ await refreshAuth ( ) ;
30243074
3025- // Set up initial command to be sent when prompt is detected
3026- // Skip initial command if we're resuming an existing session
3027- if ( initialCommand && ! secondarySessionId ) {
3028- debugLog (
3029- `[AblyCLITerminal] [Secondary] Initial command present: " ${ initialCommand } " - will be sent when prompt is detected` ,
3075+ // Send auth payload with signed config
3076+ const payload = createAuthPayload (
3077+ secondarySessionId ,
3078+ effectiveSignedConfigReference . current ,
3079+ effectiveSignatureReference . current ,
30303080 ) ;
3031- pendingSecondaryInitialCommandReference . current = initialCommand ;
3032- } else if ( initialCommand && secondarySessionId ) {
3033- debugLog (
3034- `[AblyCLITerminal] [Secondary] Skipping initial command for resumed session ${ secondarySessionId } ` ,
3035- ) ;
3036- }
3081+
3082+ if ( newSocket . readyState === WebSocket . OPEN ) {
3083+ newSocket . send ( JSON . stringify ( payload ) ) ;
3084+ }
3085+
3086+ // Set up initial command to be sent when prompt is detected
3087+ // Skip initial command if we're resuming an existing session
3088+ if ( initialCommand && ! secondarySessionId ) {
3089+ debugLog (
3090+ `[AblyCLITerminal] [Secondary] Initial command present: "${ initialCommand } " - will be sent when prompt is detected` ,
3091+ ) ;
3092+ pendingSecondaryInitialCommandReference . current = initialCommand ;
3093+ } else if ( initialCommand && secondarySessionId ) {
3094+ debugLog (
3095+ `[AblyCLITerminal] [Secondary] Skipping initial command for resumed session ${ secondarySessionId } ` ,
3096+ ) ;
3097+ }
3098+ } ) ( ) ;
30373099 } ) ;
30383100
30393101 // WebSocket message handler with binary framing support
@@ -3322,13 +3384,7 @@ const AblyCliTerminalInner = (
33223384
33233385 return newSocket ;
33243386 // eslint-disable-next-line react-hooks/exhaustive-deps -- callback is accessed via connectSecondaryWebSocketReference ref; missing deps include hoisted callbacks and state that would cause cascading recreations
3325- } , [
3326- websocketUrl ,
3327- signedConfig ,
3328- signature ,
3329- resumeOnReload ,
3330- secondarySessionId ,
3331- ] ) ;
3387+ } , [ websocketUrl , refreshAuth , resumeOnReload , secondarySessionId ] ) ;
33323388
33333389 // Keep the ref updated with the latest callback
33343390 connectSecondaryWebSocketReference . current = connectSecondaryWebSocket ;
0 commit comments