mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-21 21:29:47 +00:00
feat: cut relay tunnel bandwidth with compression and SSE replay
Relay-metered traffic drops on every channel that carried avoidable bytes; binary payloads (attachments, voice audio) are unchanged. Hub: - gzip /api/* JSON responses, gated by q-aware Accept-Encoding negotiation (explicit gzip;q=0 beats a wildcard in either order; hono's compress() alone matches by substring) - enable WebSocket permessage-deflate and default flagless ws.send() to compressed frames - Bun negotiates the extension but compresses nothing unless each send opts in, and @socket.io/bun-engine never passes the flag (measured 96 KB terminal payload -> 608 B on wire) - replay missed SSE events on reconnect: 256-event/2MB ring buffer, per-process epoch ids bound to the authenticated namespace so a token swap can never resume from a foreign cursor, standard Last-Event-ID header preferred over the ?lastEventId fallback, live broadcasts queued until the replay flushes to preserve order Web: - skip the full sessions/details/messages resync when the hub answers resume:ok - a phone unlock now costs a handshake plus the gap delta instead of refetching everything - own every EventSource retry path: take over browser-native CONNECTING retries, defer reconnects while the tab is hidden, and raise the backoff ceiling to 5 min after repeated failures - drop the 30s skills/slash-commands polling; refresh on demand without blocking the suggestion menu behind a stalled CLI RPC - remember the websocket upgrade across terminal socket reconnects
This commit is contained in:
+15
-2
@@ -207,10 +207,18 @@ function AppInner() {
|
||||
void run()
|
||||
}, [api, isPushSupported, pushPermission, requestPermission, subscribe, token])
|
||||
|
||||
const handleSseConnect = useCallback(() => {
|
||||
const handleSseConnect = useCallback((info: { resumed: boolean }) => {
|
||||
// Clear disconnected state on successful connection
|
||||
reportSseConnect()
|
||||
|
||||
// The hub replayed every event missed during the gap, so the caches
|
||||
// are already consistent - the full refetch below would only re-download
|
||||
// what the replay just delivered. First connects and long gaps arrive
|
||||
// with resumed=false and take the resync path.
|
||||
if (info.resumed && !isFirstConnectRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
// Increment token to track this specific connection
|
||||
const token = ++syncTokenRef.current
|
||||
|
||||
@@ -265,10 +273,15 @@ function AppInner() {
|
||||
void syncTailMessages(api, event.sessionId)
|
||||
}, [api, selectedSessionId])
|
||||
|
||||
const handleSessionSseConnect = useCallback(() => {
|
||||
const handleSessionSseConnect = useCallback((info: { resumed: boolean }) => {
|
||||
if (!api || !selectedSessionId) {
|
||||
return
|
||||
}
|
||||
// A resumed connection replayed messages-consumed/message events for
|
||||
// this session, so the queued-state snapshot cannot have drifted.
|
||||
if (info.resumed) {
|
||||
return
|
||||
}
|
||||
void reconcileQueuedStateAfterConnect(api, selectedSessionId).catch((error) => {
|
||||
console.error('Failed to reconcile queued state after SSE connect:', error)
|
||||
})
|
||||
|
||||
@@ -46,9 +46,12 @@ export function useSkills(
|
||||
return response
|
||||
},
|
||||
enabled: Boolean(api && sessionId),
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 30_000,
|
||||
refetchOnWindowFocus: 'always',
|
||||
// Skills change only when the user edits files on the machine, so
|
||||
// polling them on a timer just burns relay bandwidth on an answer
|
||||
// that is almost always identical. getSuggestions() refetches when
|
||||
// the user actually types "$", which is the moment freshness matters.
|
||||
staleTime: 5 * 60_000,
|
||||
refetchOnWindowFocus: true,
|
||||
gcTime: 30 * 60 * 1000,
|
||||
retry: false,
|
||||
})
|
||||
@@ -61,10 +64,12 @@ export function useSkills(
|
||||
}, [query.data])
|
||||
|
||||
const getSuggestions = useCallback(async (queryText: string): Promise<Suggestion[]> => {
|
||||
const refreshed = queryText === '$' ? await query.refetch() : null
|
||||
const currentSkills = refreshed?.data?.success
|
||||
? (refreshed.data.skills ?? [])
|
||||
: skills
|
||||
// Fire-and-forget for the same reason as useSlashCommands: the RPC can
|
||||
// stall behind a wedged CLI, and the menu must not block on it.
|
||||
if (queryText === '$') {
|
||||
void query.refetch()
|
||||
}
|
||||
const currentSkills = skills
|
||||
const recent = getRecentSkills()
|
||||
const getRecency = (name: string) => recent[name] ?? 0
|
||||
const searchTerm = queryText.startsWith('$')
|
||||
|
||||
@@ -44,9 +44,11 @@ export function useSlashCommands(
|
||||
return await api.getSlashCommands(sessionId)
|
||||
},
|
||||
enabled: Boolean(api && sessionId),
|
||||
staleTime: 30_000,
|
||||
refetchInterval: 30_000,
|
||||
refetchOnWindowFocus: 'always',
|
||||
// Same reasoning as useSkills: the command list is near-static, so it
|
||||
// is refetched when the user opens the menu with "/" rather than on a
|
||||
// 30s timer that runs for as long as the session is open.
|
||||
staleTime: 5 * 60_000,
|
||||
refetchOnWindowFocus: true,
|
||||
gcTime: 30 * 60 * 1000,
|
||||
retry: false, // Don't retry RPC failures
|
||||
})
|
||||
@@ -66,6 +68,15 @@ export function useSlashCommands(
|
||||
}, [agentType, query.data])
|
||||
|
||||
const getSuggestions = useCallback(async (queryText: string): Promise<Suggestion[]> => {
|
||||
// Opening the menu is the one moment a stale list is visible, so
|
||||
// refresh here instead of polling in the background. Fire-and-forget:
|
||||
// the /slash-commands RPC can stall for its full 30s timeout when the
|
||||
// CLI is wedged, and the menu must show cached + built-in commands
|
||||
// immediately - the refreshed list flows in via query state.
|
||||
if (queryText === '/') {
|
||||
void query.refetch()
|
||||
}
|
||||
|
||||
const searchTerm = queryText.startsWith('/')
|
||||
? queryText.slice(1).toLowerCase()
|
||||
: queryText.toLowerCase()
|
||||
@@ -105,7 +116,7 @@ export function useSlashCommands(
|
||||
content: cmd.content,
|
||||
source: cmd.source
|
||||
}))
|
||||
}, [commands])
|
||||
}, [commands, query.refetch])
|
||||
|
||||
return {
|
||||
commands,
|
||||
|
||||
@@ -92,6 +92,12 @@ export function useAgentTerminalSocket(options: UseAgentTerminalSocketOptions):
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 5000,
|
||||
transports: ['polling', 'websocket'],
|
||||
// Once a websocket upgrade has succeeded, reconnects go straight
|
||||
// to websocket instead of repeating the HTTP long-polling
|
||||
// handshake — several fewer round-trips through the relay tunnel
|
||||
// on every reconnect. First-ever connects keep the polling
|
||||
// fallback for networks that block websockets.
|
||||
rememberUpgrade: true,
|
||||
autoConnect: false
|
||||
})
|
||||
const socket = manager.socket('/terminal', {
|
||||
|
||||
+79
-6
@@ -85,6 +85,11 @@ const HEARTBEAT_WATCHDOG_INTERVAL_MS = 10_000
|
||||
const RECONNECT_BASE_DELAY_MS = 1_000
|
||||
const RECONNECT_MAX_DELAY_MS = 30_000
|
||||
const RECONNECT_JITTER_MS = 500
|
||||
// A hub that stays unreachable is usually offline for hours, not seconds.
|
||||
// Retrying every 30s forever costs a full TLS handshake per attempt through
|
||||
// the relay, so widen the ceiling once the fast retries have clearly failed.
|
||||
const RECONNECT_SLOW_AFTER_ATTEMPTS = 8
|
||||
const RECONNECT_SLOW_MAX_DELAY_MS = 300_000
|
||||
const INVALIDATION_BATCH_MS = 16
|
||||
|
||||
function sortSessionSummaries(left: SessionSummary, right: SessionSummary): number {
|
||||
@@ -288,7 +293,8 @@ function buildEventsUrl(
|
||||
baseUrl: string,
|
||||
token: string,
|
||||
subscription: SSESubscription,
|
||||
visibility: VisibilityState
|
||||
visibility: VisibilityState,
|
||||
lastEventId: string | null
|
||||
): string {
|
||||
const params = new URLSearchParams()
|
||||
params.set('token', token)
|
||||
@@ -302,6 +308,9 @@ function buildEventsUrl(
|
||||
if (subscription.machineId) {
|
||||
params.set('machineId', subscription.machineId)
|
||||
}
|
||||
if (lastEventId) {
|
||||
params.set('lastEventId', lastEventId)
|
||||
}
|
||||
|
||||
const path = `/api/events?${params.toString()}`
|
||||
try {
|
||||
@@ -318,7 +327,12 @@ export function useSSE(options: {
|
||||
subscription?: SSESubscription
|
||||
scope?: SSEScope
|
||||
onEvent: (event: SyncEvent) => void
|
||||
onConnect?: () => void
|
||||
/**
|
||||
* Fires on the server's connection-changed handshake. `resumed` is true
|
||||
* when the hub replayed everything missed since the last connection, so
|
||||
* the caller can skip its full REST resync.
|
||||
*/
|
||||
onConnect?: (info: { resumed: boolean }) => void
|
||||
onDisconnect?: (reason: string) => void
|
||||
onError?: (error: unknown) => void
|
||||
onToast?: (event: ToastEvent) => void
|
||||
@@ -339,6 +353,15 @@ export function useSSE(options: {
|
||||
}>({ sessions: false, machines: false, sessionIds: new Set(), scratchlistSessionIds: new Set() })
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const reconnectAttemptRef = useRef(0)
|
||||
// Set when a reconnect was due while the tab was hidden. Hidden tabs do
|
||||
// not schedule retries at all - they wait for the tab to come back.
|
||||
const reconnectDeferredRef = useRef(false)
|
||||
// Last SSE event id seen, keyed by the subscription it belongs to. Sent
|
||||
// as ?lastEventId on reconnects of the SAME subscription so the hub can
|
||||
// replay the gap instead of the client refetching everything. A cursor
|
||||
// from a different subscription (e.g. after a session switch) would make
|
||||
// the hub replay against the wrong filter set, hence the key check.
|
||||
const lastEventCursorRef = useRef<{ key: string; id: string } | null>(null)
|
||||
const lastActivityAtRef = useRef(0)
|
||||
const [reconnectNonce, setReconnectNonce] = useState(0)
|
||||
const [subscriptionId, setSubscriptionId] = useState<string | null>(null)
|
||||
@@ -387,15 +410,19 @@ export function useSSE(options: {
|
||||
reconnectTimerRef.current = null
|
||||
}
|
||||
reconnectAttemptRef.current = 0
|
||||
reconnectDeferredRef.current = false
|
||||
setSubscriptionId(null)
|
||||
return
|
||||
}
|
||||
|
||||
setSubscriptionId(null)
|
||||
const resumeCursor = lastEventCursorRef.current?.key === subscriptionKey
|
||||
? lastEventCursorRef.current.id
|
||||
: null
|
||||
const url = buildEventsUrl(options.baseUrl, options.token, {
|
||||
...subscription,
|
||||
sessionId: subscription.sessionId ?? undefined
|
||||
}, getVisibilityState())
|
||||
}, getVisibilityState(), resumeCursor)
|
||||
const eventSource = new EventSource(url)
|
||||
let disconnectNotified = false
|
||||
let reconnectRequested = false
|
||||
@@ -403,8 +430,23 @@ export function useSSE(options: {
|
||||
lastActivityAtRef.current = Date.now()
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
// Nobody is looking at a hidden tab, and every retry through the
|
||||
// relay costs a TLS handshake. Defer until the tab is visible
|
||||
// again; onVisibilityChange reconnects immediately at that point.
|
||||
if (getVisibilityState() === 'hidden') {
|
||||
reconnectDeferredRef.current = true
|
||||
if (reconnectTimerRef.current) {
|
||||
clearTimeout(reconnectTimerRef.current)
|
||||
reconnectTimerRef.current = null
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const attempt = reconnectAttemptRef.current
|
||||
const exponentialDelay = Math.min(RECONNECT_MAX_DELAY_MS, RECONNECT_BASE_DELAY_MS * (2 ** attempt))
|
||||
const maxDelay = attempt >= RECONNECT_SLOW_AFTER_ATTEMPTS
|
||||
? RECONNECT_SLOW_MAX_DELAY_MS
|
||||
: RECONNECT_MAX_DELAY_MS
|
||||
const exponentialDelay = Math.min(maxDelay, RECONNECT_BASE_DELAY_MS * (2 ** attempt))
|
||||
const jitter = Math.floor(Math.random() * (RECONNECT_JITTER_MS + 1))
|
||||
reconnectAttemptRef.current = attempt + 1
|
||||
if (reconnectTimerRef.current) {
|
||||
@@ -690,6 +732,13 @@ export function useSSE(options: {
|
||||
setSubscriptionId(nextId)
|
||||
}
|
||||
}
|
||||
// The connect callback fires here, not on EventSource open:
|
||||
// only the server handshake knows whether the gap was replayed
|
||||
// (`resume: 'ok'`) or the caller must resync. Older hubs omit
|
||||
// the field, which safely reads as a full resync.
|
||||
const resumed = data && typeof data === 'object'
|
||||
&& (data as { resume?: unknown }).resume === 'ok'
|
||||
onConnectRef.current?.({ resumed: Boolean(resumed) })
|
||||
}
|
||||
|
||||
if (event.type === 'toast') {
|
||||
@@ -815,6 +864,15 @@ export function useSSE(options: {
|
||||
}
|
||||
|
||||
handleSyncEvent(parsed as SyncEvent)
|
||||
|
||||
// Track the hub's replay cursor - after handling, so a throwing
|
||||
// handler leaves the cursor behind the event and the hub replays
|
||||
// it on the next reconnect (at-least-once). EventSource keeps
|
||||
// lastEventId sticky across frames, so heartbeats (no id field)
|
||||
// simply repeat the previous cursor.
|
||||
if (message.lastEventId) {
|
||||
lastEventCursorRef.current = { key: subscriptionKey, id: message.lastEventId }
|
||||
}
|
||||
}
|
||||
|
||||
eventSource.onmessage = handleMessage
|
||||
@@ -824,9 +882,11 @@ export function useSSE(options: {
|
||||
reconnectTimerRef.current = null
|
||||
}
|
||||
reconnectAttemptRef.current = 0
|
||||
reconnectDeferredRef.current = false
|
||||
disconnectNotified = false
|
||||
lastActivityAtRef.current = Date.now()
|
||||
onConnectRef.current?.()
|
||||
// onConnect intentionally does NOT fire here: it fires on the
|
||||
// connection-changed handshake, which carries the resume verdict.
|
||||
}
|
||||
eventSource.onerror = (error) => {
|
||||
onErrorRef.current?.(error)
|
||||
@@ -834,7 +894,12 @@ export function useSSE(options: {
|
||||
requestReconnect('closed')
|
||||
return
|
||||
}
|
||||
notifyDisconnect('error')
|
||||
// CONNECTING means the browser would keep retrying natively - at
|
||||
// its own few-second interval, with the original (stale) URL, and
|
||||
// regardless of tab visibility. Take the source down and route the
|
||||
// retry through our scheduler so hidden-tab deferral and the slow
|
||||
// backoff actually govern every reconnect path.
|
||||
requestReconnect('transport-error')
|
||||
}
|
||||
|
||||
const watchdogTimer = setInterval(() => {
|
||||
@@ -856,6 +921,14 @@ export function useSSE(options: {
|
||||
// HEARTBEAT_WATCHDOG_INTERVAL_MS after switching back.
|
||||
const onVisibilityChange = () => {
|
||||
if (getVisibilityState() !== 'visible') return
|
||||
// A retry fell due while the tab was hidden and was deliberately
|
||||
// not scheduled. Run it now, before the identity guard below:
|
||||
// requestReconnect has already cleared eventSourceRef.
|
||||
if (reconnectDeferredRef.current) {
|
||||
reconnectDeferredRef.current = false
|
||||
setReconnectNonce((value) => value + 1)
|
||||
return
|
||||
}
|
||||
if (eventSourceRef.current !== eventSource) return
|
||||
if (Date.now() - lastActivityAtRef.current >= HEARTBEAT_STALE_MS) {
|
||||
requestReconnect('visibility-recovery')
|
||||
|
||||
@@ -123,6 +123,9 @@ export function useTerminalSocket(options: UseTerminalSocketOptions): {
|
||||
reconnectionDelay: 1000,
|
||||
reconnectionDelayMax: 5000,
|
||||
transports: ['polling', 'websocket'],
|
||||
// Skip the HTTP long-polling phase on reconnects once a websocket
|
||||
// upgrade has succeeded — see useAgentTerminalSocket.
|
||||
rememberUpgrade: true,
|
||||
autoConnect: false
|
||||
})
|
||||
const socket = manager.socket('/terminal', {
|
||||
|
||||
Reference in New Issue
Block a user