204 lines
6.9 KiB
TypeScript
204 lines
6.9 KiB
TypeScript
import { isGatewayReauthRequired, resolveGatewayWsUrl } from '@hermes/shared'
|
|
import { useStore } from '@nanostores/react'
|
|
import { useCallback, useEffect, useRef } from 'react'
|
|
|
|
import type { HermesGateway } from '@/hermes'
|
|
import { RECONNECT_ATTEMPT_TIMEOUT_MS, withTimeout } from '@/lib/with-timeout'
|
|
import { $gateway, ensureActiveGatewayOpen, isActivePrimary } from '@/store/gateway'
|
|
import { $activeGatewayProfile } from '@/store/profile'
|
|
import { $gatewayState, setConnection } from '@/store/session'
|
|
|
|
export function useGatewayRequest() {
|
|
const gatewayState = useStore($gatewayState)
|
|
// Reactive companion to `gatewayRef`. The ref exists so `requestGateway`
|
|
// keeps a stable identity and always reaches the live socket, but it is only
|
|
// populated by the subscription effect below — i.e. AFTER the first render.
|
|
// A component that reads `gatewayRef.current` while rendering therefore sees
|
|
// null on mount, and if the connection state doesn't happen to flip
|
|
// afterwards it never re-renders to pick the instance up. Anything that needs
|
|
// the gateway as a render-time VALUE (props, memo deps) must use this.
|
|
const gateway = useStore($gateway) as HermesGateway | null
|
|
const gatewayRef = useRef<HermesGateway | null>(null)
|
|
|
|
const connectionRef = useRef<Awaited<ReturnType<NonNullable<typeof window.hermesDesktop>['getConnection']>> | null>(
|
|
null
|
|
)
|
|
|
|
const gatewayStateRef = useRef(gatewayState)
|
|
const reconnectingRef = useRef<Promise<HermesGateway | null> | null>(null)
|
|
// Holds the reauth error from the most recent failed reconnect so
|
|
// requestGateway can surface the gateway's "session expired, sign in again"
|
|
// message instead of the opaque "connection closed" that triggered the retry.
|
|
const reauthErrorRef = useRef<unknown>(null)
|
|
|
|
// eslint-disable-next-line no-restricted-syntax -- legitimate non-atom ref write (see eslint rule comment)
|
|
useEffect(() => {
|
|
gatewayStateRef.current = gatewayState
|
|
}, [gatewayState])
|
|
|
|
// Track the active gateway (primary or a background profile's socket) so
|
|
// outbound requests and overlay props always target the focused profile.
|
|
useEffect(
|
|
() =>
|
|
$gateway.subscribe(gateway => {
|
|
gatewayRef.current = gateway as HermesGateway | null
|
|
}),
|
|
[]
|
|
)
|
|
|
|
const ensureGatewayOpen = useCallback(async () => {
|
|
const existing = gatewayRef.current
|
|
|
|
if (!existing) {
|
|
return null
|
|
}
|
|
|
|
if (gatewayStateRef.current === 'open') {
|
|
return existing
|
|
}
|
|
|
|
if (reconnectingRef.current) {
|
|
return reconnectingRef.current
|
|
}
|
|
|
|
reconnectingRef.current = (async () => {
|
|
const desktop = window.hermesDesktop
|
|
|
|
if (!desktop) {
|
|
return null
|
|
}
|
|
|
|
reauthErrorRef.current = null
|
|
|
|
try {
|
|
// Reconnect to whichever profile the gateway is currently routed to (not
|
|
// always the primary), so a sleep/wake reconnect keeps the user on the
|
|
// profile they were chatting in. Both awaits below are IPC round-trips
|
|
// into the main process with no timeout of their own (#93454) — a
|
|
// wedged main-process round-trip otherwise hangs this await forever,
|
|
// latching reconnectingRef.current so every later requestGateway() call
|
|
// returns the same never-settling promise. Bound the same way
|
|
// use-gateway-boot.ts bounds the primary boot/soft-switch equivalents.
|
|
const conn = await withTimeout(
|
|
desktop.getConnection($activeGatewayProfile.get()),
|
|
RECONNECT_ATTEMPT_TIMEOUT_MS,
|
|
'Timed out reconnecting to Hermes backend'
|
|
)
|
|
|
|
connectionRef.current = conn
|
|
setConnection(conn)
|
|
|
|
// Re-mint the WS URL before reconnecting. OAuth tickets are single-use
|
|
// and short-lived, so the cached conn.wsUrl ticket is dead here;
|
|
// resolveGatewayWsUrl() never connects with a stale ticket. An explicit
|
|
// auth rejection becomes a reauth error; transport failures remain
|
|
// retryable. Stash only the former so requestGateway can show the
|
|
// actionable "sign in again" message.
|
|
const wsUrl = await withTimeout(
|
|
resolveGatewayWsUrl(desktop, conn),
|
|
RECONNECT_ATTEMPT_TIMEOUT_MS,
|
|
'Timed out re-minting the gateway WebSocket URL'
|
|
)
|
|
|
|
await existing.connect(wsUrl)
|
|
|
|
return existing
|
|
} catch (error) {
|
|
if (isGatewayReauthRequired(error)) {
|
|
reauthErrorRef.current = error
|
|
}
|
|
|
|
connectionRef.current = null
|
|
setConnection(null)
|
|
|
|
return null
|
|
} finally {
|
|
reconnectingRef.current = null
|
|
}
|
|
})()
|
|
|
|
return reconnectingRef.current
|
|
}, [])
|
|
|
|
const requestGateway = useCallback(
|
|
async <T>(method: string, params: Record<string, unknown> = {}, timeoutMs?: number, signal?: AbortSignal) => {
|
|
const gateway = gatewayRef.current
|
|
|
|
if (!gateway) {
|
|
throw new Error('Hermes gateway unavailable')
|
|
}
|
|
|
|
try {
|
|
return await gateway.request<T>(method, params, timeoutMs, signal)
|
|
} catch (error) {
|
|
if (!isGatewayTransportError(error)) {
|
|
throw error
|
|
}
|
|
|
|
// Primary keeps the OAuth-aware reconnect (remote gateways re-mint a
|
|
// single-use ticket). Background profiles stay on the registry's
|
|
// connection-owned reconnect path, including composite remote/SSH
|
|
// sources.
|
|
const recovered = isActivePrimary() ? await ensureGatewayOpen() : await ensureActiveGatewayOpen()
|
|
|
|
if (!recovered) {
|
|
// Prefer the reauth error from the failed reconnect (OAuth session
|
|
// expired) over the generic transport error that triggered the retry.
|
|
const reauthError = reauthErrorRef.current
|
|
reauthErrorRef.current = null
|
|
|
|
if (reauthError) {
|
|
throw reauthError
|
|
}
|
|
|
|
throw error
|
|
}
|
|
|
|
return recovered.request<T>(method, params, timeoutMs, signal)
|
|
}
|
|
},
|
|
[ensureGatewayOpen]
|
|
)
|
|
|
|
return { connectionRef, gateway, gatewayRef, requestGateway }
|
|
}
|
|
|
|
const GATEWAY_TRANSPORT_ERROR_CODES = new Set([
|
|
'ECONNABORTED',
|
|
'ECONNREFUSED',
|
|
'ECONNRESET',
|
|
'EHOSTUNREACH',
|
|
'ENETUNREACH',
|
|
'ENOTFOUND',
|
|
'EPIPE',
|
|
'ETIMEDOUT',
|
|
'ERR_NETWORK',
|
|
'ERR_SOCKET_CLOSED'
|
|
])
|
|
|
|
function errorCode(value: unknown): string | null {
|
|
if (typeof value !== 'object' || value === null) {
|
|
return null
|
|
}
|
|
|
|
const code = (value as { code?: unknown }).code
|
|
|
|
return typeof code === 'string' ? code.toUpperCase() : null
|
|
}
|
|
|
|
function isGatewayTransportError(error: unknown): boolean {
|
|
const message = error instanceof Error ? error.message : String(error)
|
|
|
|
if (/not connected|connection closed|connection reset|ECONNRESET/i.test(message)) {
|
|
return true
|
|
}
|
|
|
|
const cause = typeof error === 'object' && error !== null ? (error as { cause?: unknown }).cause : undefined
|
|
|
|
return [error, cause].some(value => {
|
|
const code = errorCode(value)
|
|
|
|
return code !== null && GATEWAY_TRANSPORT_ERROR_CODES.has(code)
|
|
})
|
|
}
|