Four backoff formulas (ui-tui 1000/30s, desktop 300/15s jittered, web events
1000/30s, web PTY inline 250/3s cap 5 — untested) collapse into
apps/shared/src/reconnect-backoff.ts::reconnectBackoffDelayMs(attempt,
{baseDelayMs, capMs, jitter}). Every caller keeps its own parameters
(table in the PR body); the PTY ladder gains a test.
web/src/components/ChatSidebar.tsx hand-rolled a third WebSocket frame
dispatcher (`new WebSocket` + JSON.parse + `frame.method === "event"` switch
+ a private RpcEnvelope re-declaring shared JsonRpcFrame) for /api/events.
That socket now goes through EventsFeedClient, a notification-only subclass
of the shared JsonRpcGatewayClient (replay off, heartbeat off, connect
timeout covering ticket minting); the effect keeps only the retry ladder and
the banner. Both sidebar clients are now created once per component instead
of per `version` bump, so the shared client's seq watermarks survive a drop
and its `session.events.since` gap replay can actually fire for web
(previously the client was rebuilt on every reconnect and replay never ran).
Behavior change: web sidecar reconnects reuse the same JsonRpcGatewayClient
(gap replay now runs); the events feed's handshake `error`+`close` pair is one
`closed` transition (one retry timer, as before); no parameter of any
backoff ladder changed.
94 lines
3.4 KiB
TypeScript
94 lines
3.4 KiB
TypeScript
/**
|
|
* `/api/events` subscriber for the dashboard chat sidebar.
|
|
*
|
|
* A notification-only view of the shared JSON-RPC client: the server never
|
|
* answers requests on this socket (subscribers don't speak), so replay is
|
|
* off and `request()` is not part of the surface. One instance lives for
|
|
* the component's whole life — the owner calls `connect()` again after a
|
|
* drop instead of building a new client — so the shared per-generation
|
|
* guards (stale-socket close, connect timeout, `error`+`close` collapsed
|
|
* into one `closed` transition) apply to every attempt.
|
|
*/
|
|
|
|
import { JsonRpcGatewayClient, type ConnectionState, type GatewayEvent, type GatewayEventName } from '@hermes/shared'
|
|
|
|
import { buildWsUrl } from '@/lib/api'
|
|
import { maybeReloadForLoopbackWsAuthFailure } from '@/lib/dashboard-auth-reload'
|
|
import { EVENTS_CONNECT_TIMEOUT_MS } from '@/lib/events-reconnect'
|
|
|
|
export type { ConnectionState, GatewayEvent, GatewayEventName }
|
|
|
|
type CloseHandler = (code: number | undefined) => void
|
|
|
|
export class EventsFeedClient extends JsonRpcGatewayClient {
|
|
private readonly closeHandlers = new Set<CloseHandler>()
|
|
private closeCode: number | null | undefined = undefined
|
|
|
|
constructor() {
|
|
super({
|
|
closedErrorMessage: 'events feed closed',
|
|
connectErrorMessage: 'events feed connection failed',
|
|
connectTimeoutMs: EVENTS_CONNECT_TIMEOUT_MS,
|
|
heartbeatDeadlineMs: 0,
|
|
heartbeatIntervalMs: 0,
|
|
onSocketClose: event => {
|
|
this.closeCode = event.code
|
|
// Loopback + stale token: the page reloads; nobody should retry.
|
|
return maybeReloadForLoopbackWsAuthFailure(event.code)
|
|
},
|
|
replay: false,
|
|
requestIdPrefix: 'e'
|
|
})
|
|
this.onState(state => {
|
|
if (state === 'closed' || state === 'error') {
|
|
const code = this.closeCode ?? undefined
|
|
this.closeCode = undefined
|
|
for (const handler of this.closeHandlers) {
|
|
handler(code)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
|
|
/** Close code of the most recent drop; `null` before any socket was dialed. */
|
|
get lastCloseCode(): number | null | undefined {
|
|
return this.closeCode
|
|
}
|
|
|
|
/**
|
|
* Called once per generation that ends without opening or after opening:
|
|
* `code` is the WebSocket close code, or `undefined` when the handshake
|
|
* timed out / errored without a close frame.
|
|
*/
|
|
onClose(handler: CloseHandler): () => void {
|
|
this.closeHandlers.add(handler)
|
|
return () => this.closeHandlers.delete(handler)
|
|
}
|
|
|
|
/** Mint a fresh single-use ticket and dial `/api/events` for `channel`. */
|
|
async connect(channel: string): Promise<void> {
|
|
this.closeCode = null
|
|
// Cover ticket minting with the same deadline as the handshake: a stalled
|
|
// pre-socket request otherwise emits no close event and strands the retry
|
|
// loop at its last "reconnecting in …" banner.
|
|
const url = await withTimeout(buildWsUrl('/api/events', { channel }), EVENTS_CONNECT_TIMEOUT_MS)
|
|
await super.connect(url)
|
|
}
|
|
}
|
|
|
|
function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
|
|
return new Promise<T>((resolve, reject) => {
|
|
const timer = setTimeout(() => reject(new Error('events feed ticket request timed out')), ms)
|
|
promise.then(
|
|
value => {
|
|
clearTimeout(timer)
|
|
resolve(value)
|
|
},
|
|
(error: unknown) => {
|
|
clearTimeout(timer)
|
|
reject(error instanceof Error ? error : new Error(String(error)))
|
|
}
|
|
)
|
|
})
|
|
}
|