Files
hapi/web/src/hooks/useTerminalSocket.ts
T
weishu 46fc83d211 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
2026-08-05 14:59:31 +08:00

235 lines
7.2 KiB
TypeScript

import { useCallback, useEffect, useRef, useState } from 'react'
import { Manager, type Socket } from 'socket.io-client'
type TerminalConnectionState =
| { status: 'idle' }
| { status: 'connecting' }
| { status: 'connected' }
| { status: 'error'; error: string }
type UseTerminalSocketOptions = {
baseUrl: string
token: string
sessionId: string
terminalId: string
}
type TerminalReadyPayload = {
terminalId: string
}
type TerminalOutputPayload = {
terminalId: string
data: string
}
type TerminalExitPayload = {
terminalId: string
code: number | null
signal: string | null
}
type TerminalErrorPayload = {
terminalId: string
message: string
}
export function useTerminalSocket(options: UseTerminalSocketOptions): {
state: TerminalConnectionState
connect: (cols: number, rows: number) => void
write: (data: string) => void
resize: (cols: number, rows: number) => void
disconnect: () => void
onOutput: (handler: (data: string) => void) => void
onExit: (handler: (code: number | null, signal: string | null) => void) => void
} {
const [state, setState] = useState<TerminalConnectionState>({ status: 'idle' })
const socketRef = useRef<Socket | null>(null)
const outputHandlerRef = useRef<(data: string) => void>(() => {})
const exitHandlerRef = useRef<(code: number | null, signal: string | null) => void>(() => {})
const sessionIdRef = useRef(options.sessionId)
const terminalIdRef = useRef(options.terminalId)
const tokenRef = useRef(options.token)
const baseUrlRef = useRef(options.baseUrl)
const lastSizeRef = useRef<{ cols: number; rows: number } | null>(null)
useEffect(() => {
sessionIdRef.current = options.sessionId
terminalIdRef.current = options.terminalId
baseUrlRef.current = options.baseUrl
}, [options.sessionId, options.terminalId, options.baseUrl])
useEffect(() => {
tokenRef.current = options.token
const socket = socketRef.current
if (!socket) {
return
}
if (!options.token) {
if (socket.connected) {
socket.disconnect()
}
return
}
socket.auth = { token: options.token }
if (socket.connected) {
socket.disconnect()
socket.connect()
}
}, [options.token])
const isCurrentTerminal = useCallback((terminalId: string) => terminalId === terminalIdRef.current, [])
const emitCreate = useCallback((socket: Socket, size: { cols: number; rows: number }) => {
socket.emit('terminal:create', {
sessionId: sessionIdRef.current,
terminalId: terminalIdRef.current,
cols: size.cols,
rows: size.rows
})
}, [])
const setErrorState = useCallback((message: string) => {
setState({ status: 'error', error: message })
}, [])
const connect = useCallback((cols: number, rows: number) => {
lastSizeRef.current = { cols, rows }
const token = tokenRef.current
const sessionId = sessionIdRef.current
const terminalId = terminalIdRef.current
if (!token || !sessionId || !terminalId) {
setErrorState('Missing terminal credentials.')
return
}
if (socketRef.current) {
const socket = socketRef.current
socket.auth = { token }
if (socket.connected) {
emitCreate(socket, { cols, rows })
} else {
socket.connect()
}
setState({ status: 'connecting' })
return
}
const manager = new Manager(baseUrlRef.current, {
path: '/socket.io/',
reconnection: true,
reconnectionAttempts: Infinity,
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', {
auth: { token }
})
socketRef.current = socket
setState({ status: 'connecting' })
socket.on('connect', () => {
const size = lastSizeRef.current ?? { cols, rows }
setState({ status: 'connecting' })
emitCreate(socket, size)
})
socket.on('terminal:ready', (payload: TerminalReadyPayload) => {
if (!isCurrentTerminal(payload.terminalId)) {
return
}
setState({ status: 'connected' })
})
socket.on('terminal:output', (payload: TerminalOutputPayload) => {
if (!isCurrentTerminal(payload.terminalId)) {
return
}
outputHandlerRef.current(payload.data)
})
socket.on('terminal:exit', (payload: TerminalExitPayload) => {
if (!isCurrentTerminal(payload.terminalId)) {
return
}
exitHandlerRef.current(payload.code, payload.signal)
setErrorState('Terminal exited.')
})
socket.on('terminal:error', (payload: TerminalErrorPayload) => {
if (!isCurrentTerminal(payload.terminalId)) {
return
}
setErrorState(payload.message)
})
socket.on('connect_error', (error) => {
const message = error instanceof Error ? error.message : 'Connection error'
setErrorState(message)
})
socket.on('disconnect', (reason) => {
if (reason === 'io client disconnect') {
setState({ status: 'idle' })
return
}
setErrorState(`Disconnected: ${reason}`)
})
socket.connect()
}, [emitCreate, setErrorState, isCurrentTerminal])
const write = useCallback((data: string) => {
const socket = socketRef.current
if (!socket || !socket.connected) {
return
}
socket.emit('terminal:write', { terminalId: terminalIdRef.current, data })
}, [])
const resize = useCallback((cols: number, rows: number) => {
lastSizeRef.current = { cols, rows }
const socket = socketRef.current
if (!socket || !socket.connected) {
return
}
socket.emit('terminal:resize', { terminalId: terminalIdRef.current, cols, rows })
}, [])
const disconnect = useCallback(() => {
const socket = socketRef.current
if (!socket) {
return
}
socket.removeAllListeners()
socket.disconnect()
socketRef.current = null
setState({ status: 'idle' })
}, [])
const onOutput = useCallback((handler: (data: string) => void) => {
outputHandlerRef.current = handler
}, [])
const onExit = useCallback((handler: (code: number | null, signal: string | null) => void) => {
exitHandlerRef.current = handler
}, [])
return {
state,
connect,
write,
resize,
disconnect,
onOutput,
onExit
}
}