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:
weishu
2026-08-05 14:59:31 +08:00
parent 27bc6bade3
commit 46fc83d211
16 changed files with 905 additions and 51 deletions
+15 -2
View File
@@ -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)
})
+12 -7
View File
@@ -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('$')
+15 -4
View File
@@ -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,
+6
View File
@@ -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
View File
@@ -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')
+3
View File
@@ -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', {