fix: recover chat replies and organize setup navigation [skip ci]
Targeted source-only patch from reviewed local e370485. Adds bounded realtime/catch-up recovery and moves the setup wizard under System. Static checks only; regression tests, full build and runtime verification have not been run. No deployment. Separate hardening-review branch is not merged. Expected tree: 6235478b9a.
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
// Modified for targeted chat realtime recovery; see docs/fixes/chat-realtime-recovery.md.
|
||||
"use client"
|
||||
|
||||
export type RealtimeConnectionStatus = "connecting" | "connected" | "disconnected"
|
||||
@@ -14,6 +15,7 @@ type RealtimeConnectionManagerOptions = {
|
||||
onError?: (event: Event, socket: WebSocket) => void
|
||||
onConnectError?: (error: unknown) => void
|
||||
buildPingMessage?: () => string
|
||||
connectTimeoutMs?: number
|
||||
pingIntervalMs?: number
|
||||
reconnectBaseDelayMs?: number
|
||||
reconnectMaxDelayMs?: number
|
||||
@@ -24,6 +26,7 @@ type DisconnectOptions = {
|
||||
updateStatus?: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_CONNECT_TIMEOUT_MS = 15000
|
||||
const DEFAULT_PING_INTERVAL_MS = 20000
|
||||
const DEFAULT_RECONNECT_BASE_DELAY_MS = 2000
|
||||
const DEFAULT_RECONNECT_MAX_DELAY_MS = 30000
|
||||
@@ -32,12 +35,20 @@ export function createRealtimeConnectionManager(
|
||||
options: RealtimeConnectionManagerOptions
|
||||
) {
|
||||
let socket: WebSocket | null = null
|
||||
let connectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let pingTimer: ReturnType<typeof setInterval> | null = null
|
||||
let reconnectAttempt = 0
|
||||
let reconnectEnabled = false
|
||||
let connectEpoch = 0
|
||||
|
||||
const clearConnectTimer = () => {
|
||||
if (connectTimer !== null) {
|
||||
globalThis.clearTimeout(connectTimer)
|
||||
connectTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const clearReconnectTimer = () => {
|
||||
if (reconnectTimer !== null) {
|
||||
globalThis.clearTimeout(reconnectTimer)
|
||||
@@ -53,6 +64,7 @@ export function createRealtimeConnectionManager(
|
||||
}
|
||||
|
||||
const clearTimers = () => {
|
||||
clearConnectTimer()
|
||||
clearReconnectTimer()
|
||||
clearPingTimer()
|
||||
}
|
||||
@@ -97,6 +109,16 @@ export function createRealtimeConnectionManager(
|
||||
options.onStatusChange?.("connecting")
|
||||
|
||||
const currentEpoch = ++connectEpoch
|
||||
connectTimer = globalThis.setTimeout(() => {
|
||||
if (currentEpoch !== connectEpoch) {
|
||||
return
|
||||
}
|
||||
disconnect({ reconnect: true, updateStatus: false })
|
||||
options.onStatusChange?.("disconnected")
|
||||
// Callers already localize their fallback for non-Error failures.
|
||||
options.onConnectError?.({ code: "realtime_connect_timeout" })
|
||||
scheduleReconnect()
|
||||
}, options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS)
|
||||
|
||||
if (options.beforeConnect) {
|
||||
try {
|
||||
@@ -105,6 +127,7 @@ export function createRealtimeConnectionManager(
|
||||
if (currentEpoch !== connectEpoch || !reconnectEnabled) {
|
||||
return
|
||||
}
|
||||
clearConnectTimer()
|
||||
options.onStatusChange?.("disconnected")
|
||||
options.onConnectError?.(error)
|
||||
scheduleReconnect()
|
||||
@@ -112,11 +135,12 @@ export function createRealtimeConnectionManager(
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
currentEpoch !== connectEpoch ||
|
||||
!reconnectEnabled ||
|
||||
!(options.canReconnect?.() ?? true)
|
||||
) {
|
||||
if (currentEpoch !== connectEpoch || !reconnectEnabled) {
|
||||
return
|
||||
}
|
||||
if (!(options.canReconnect?.() ?? true)) {
|
||||
clearConnectTimer()
|
||||
options.onStatusChange?.("disconnected")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -124,6 +148,7 @@ export function createRealtimeConnectionManager(
|
||||
try {
|
||||
nextSocket = options.createSocket()
|
||||
} catch (error) {
|
||||
clearConnectTimer()
|
||||
options.onStatusChange?.("disconnected")
|
||||
options.onConnectError?.(error)
|
||||
scheduleReconnect()
|
||||
@@ -136,6 +161,7 @@ export function createRealtimeConnectionManager(
|
||||
if (socket !== nextSocket) {
|
||||
return
|
||||
}
|
||||
clearConnectTimer()
|
||||
clearReconnectTimer()
|
||||
clearPingTimer()
|
||||
reconnectAttempt = 0
|
||||
@@ -157,15 +183,15 @@ export function createRealtimeConnectionManager(
|
||||
})
|
||||
|
||||
nextSocket.addEventListener("close", (event) => {
|
||||
clearPingTimer()
|
||||
const isCurrentSocket = socket === nextSocket
|
||||
if (isCurrentSocket) {
|
||||
setSocket(null)
|
||||
}
|
||||
options.onClose?.(event, nextSocket)
|
||||
if (!isCurrentSocket) {
|
||||
// A replaced socket may finish closing after the next one has opened.
|
||||
// It must not clear that connection's heartbeat or subscription state.
|
||||
if (socket !== nextSocket) {
|
||||
return
|
||||
}
|
||||
clearConnectTimer()
|
||||
clearPingTimer()
|
||||
setSocket(null)
|
||||
options.onClose?.(event, nextSocket)
|
||||
if (canReconnect()) {
|
||||
scheduleReconnect()
|
||||
} else {
|
||||
@@ -178,12 +204,17 @@ export function createRealtimeConnectionManager(
|
||||
return
|
||||
}
|
||||
options.onError?.(event, nextSocket)
|
||||
// An error need not be followed promptly by close. Detach it now so
|
||||
// callers cannot keep reusing a failed OPEN/CONNECTING socket.
|
||||
disconnect({ reconnect: true, updateStatus: false })
|
||||
options.onStatusChange?.("disconnected")
|
||||
scheduleReconnect()
|
||||
})
|
||||
}
|
||||
|
||||
const disconnect = (disconnectOptions?: DisconnectOptions) => {
|
||||
// A timed-out or abandoned authentication wait must not open a late socket.
|
||||
connectEpoch += 1
|
||||
reconnectEnabled = disconnectOptions?.reconnect ?? false
|
||||
clearTimers()
|
||||
if (!reconnectEnabled) {
|
||||
|
||||
Reference in New Issue
Block a user