Files
app/frontend/src/components/Flow/useFlowSocket.ts
T
stroblmeandClaude Opus 5 cec31ba853 Home mosaic, multi-select delete, offline banner and loading states
- Home puts the dashboards beside the flows: two equal-height columns,
  capped and scrollable, most recently worked on first. Each tile is a
  schematic footprint built from the stored widget placements.
- Flows and dashboards can be picked by long press or ctrl-click; the
  create button becomes a trash and one dialog covers the batch.
- The offline banner is drawn on the body so it centres on the viewport,
  and the live socket now releases the offline latch a stray 503 set.
- A boot spinner before React's first commit, a router pending screen for
  code-split pages, and skeletons where an empty list used to flash.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu
2026-08-22 12:02:14 +02:00

315 lines
10 KiB
TypeScript

import type { QueryClient } from "@tanstack/react-query"
import { useQueryClient } from "@tanstack/react-query"
import { useEffect } from "react"
import { OpenAPI } from "@/client"
import { dashboardKeys, panelKeys } from "@/components/Dashboard/queries"
import { connectionStore } from "@/lib/connectionStore"
import { apiToken } from "@/lib/portal"
import { type LogLine, liveStore, type ValueSource } from "./liveStore"
import { flowKeys } from "./queries"
const RECONNECT_MIN = 1000
const RECONNECT_MAX = 30000
/**
* Consecutive token rejections before the session is given up on.
*
* A 1008 means the server would not take the token. Under a portal that is
* routine — the handoff token lasts twelve hours against a local session's
* eight days — so it has to be recoverable rather than fatal: the token is read
* again for every attempt, so a refreshed one is picked up. A session that has
* genuinely been revoked fails all three and falls through to the caller's auth
* handler instead of retrying forever.
*/
const AUTH_ATTEMPTS = 3
type FlowEvent =
| {
type: "snapshot"
values: Record<string, { value: unknown; ts: number | null }>
nodes: { id: string; status: string; error?: string | null }[]
paused?: string[]
logs?: LogLine[]
/** Emission counts per qualified node. Absent on older installations. */
emits?: Record<string, number>
}
| {
type: "message_value"
name: string
value: unknown
ts: number
source?: ValueSource
}
| { type: "node_started"; node: string }
| {
type: "node_executed"
flow?: string
node: string
outputs: number
duration_ms?: number
}
| {
type: "node_error"
flow?: string
node: string
error: string
ts?: number
}
| { type: "node_status"; node: string; status: string; error?: string | null }
| ({ type: "node_log" } & LogLine)
| { type: "flow_paused"; flow: string; paused: boolean }
| {
type: "node_health"
flow?: string
node: string
health: "ok" | "down" | "unknown"
detail?: string | null
ts?: number
}
| { type: "flow_quarantined"; flow: string; error?: string; ts?: number }
| { type: "engine_degraded"; reason?: string; ts?: number }
| { type: "engine_fatal"; reason?: string; ts?: number }
| {
type: "cascade_dropped"
flow?: string
node?: string
deliveries?: number
ts?: number
}
| {
type: "queue_unavailable"
flow?: string
node?: string
error?: string
ts?: number
}
| {
type: "pipeline_rebuilt"
nodes: { id: string; status: string; error?: string | null }[]
paused?: string[]
}
| { type: "dashboard_changed"; dashboard?: string; ts?: number }
function socketUrl(): string {
const base = String(OpenAPI.BASE || window.location.origin)
// Concatenated rather than resolved: an absolute path as the second argument
// to `new URL` discards the base's own path, which under a portal
// (`https://host/i/{id}`) would aim the socket at the wrong place entirely.
const url = new URL(`${base.replace(/\/$/, "")}/api/v1/flows/ws`)
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
// Browsers cannot set headers on a websocket handshake, so the token rides
// in the query string. Read per attempt, so a reconnect after the old one
// expired carries whatever is current rather than what we started with.
url.searchParams.set("token", apiToken())
return url.toString()
}
/*
* One socket for the page, owned here rather than by a component.
*
* Several places want live events at once — the app shell, the brain graph on
* Home, the editor — and React mounts and unmounts them in an order none of
* them controls: passive effects run children before parents, and StrictMode
* runs mount → unmount → mount. Keeping the socket inside whichever hook
* instance ran first made that one its owner, so leaving Home closed the socket
* while the shell kept the count above zero and nothing ever reopened it. The
* connection lives out here now with a plain refcount; a component only says
* whether it is still watching.
*/
let socket: WebSocket | null = null
let timer: ReturnType<typeof setTimeout> | null = null
let retry = RECONNECT_MIN
/** How many mounted components want the socket open. */
let watchers = 0
/** Consecutive 1008s, cleared by a handshake the server accepts. */
let rejected = 0
let client: QueryClient | null = null
const authHandlers = new Set<() => void>()
function schedule() {
if (timer) return
timer = setTimeout(() => {
timer = null
connect()
}, retry)
retry = Math.min(retry * 2, RECONNECT_MAX)
}
function connect() {
if (watchers === 0 || socket || timer) return
const ws = new WebSocket(socketUrl())
socket = ws
ws.onopen = () => {
retry = RECONNECT_MIN
rejected = 0
liveStore.setConnected(true)
connectionStore.setSocketOpen(true)
// Whatever happened while the socket was down was missed, so nothing
// held in cache can be trusted to still be current.
client?.invalidateQueries()
}
ws.onmessage = (event) => {
const message: FlowEvent = JSON.parse(event.data)
switch (message.type) {
case "snapshot":
liveStore.setValues(message.values)
liveStore.setStatuses(message.nodes ?? [])
liveStore.setPausedFlows(message.paused ?? [])
liveStore.setLogs(message.logs ?? [])
// Missing against an installation older than this bundle; the graph
// then starts from zero the way it always did.
liveStore.setEmits(message.emits ?? {})
break
case "message_value":
liveStore.setValue(message.name, {
value: message.value,
ts: message.ts,
source: message.source,
})
break
case "node_started":
liveStore.setStatus(message.node, { status: "running" })
break
case "node_executed":
liveStore.setStatus(message.node, { status: "success" })
if (message.outputs > 0) liveStore.recordEmit(message.node)
break
case "node_error":
liveStore.setStatus(message.node, {
status: "error",
error: message.error,
})
liveStore.recordEngineEvent({
type: message.type,
flow: message.flow,
node: message.node,
detail: message.error,
ts: message.ts ?? Date.now() / 1000,
})
break
case "node_health":
liveStore.setHealth(message.node, {
health: message.health,
detail: message.detail,
})
if (message.health === "down") {
liveStore.recordEngineEvent({
type: message.type,
flow: message.flow,
node: message.node,
detail: message.detail ?? "Reported itself down.",
ts: message.ts ?? Date.now() / 1000,
})
}
break
case "flow_quarantined":
case "engine_degraded":
case "engine_fatal":
case "cascade_dropped":
case "queue_unavailable":
liveStore.recordEngineEvent({
type: message.type,
flow: "flow" in message ? message.flow : undefined,
node: "node" in message ? message.node : undefined,
detail:
("error" in message ? message.error : undefined) ??
("reason" in message ? message.reason : undefined) ??
"",
ts: message.ts ?? Date.now() / 1000,
})
break
case "node_status":
liveStore.setStatus(message.node, {
status: message.status as "active" | "error",
error: message.error,
})
break
case "node_log":
liveStore.appendLog(message)
break
case "flow_paused":
liveStore.setPaused(message.flow, message.paused)
break
case "pipeline_rebuilt":
liveStore.setStatuses(message.nodes)
liveStore.setPausedFlows(message.paused ?? [])
// Someone published or started a flow, here or in another tab: the
// markers on the flow chips are stale until the list is refetched.
client?.invalidateQueries({ queryKey: flowKeys.all })
break
case "dashboard_changed":
// Someone published, or changed which dashboards a panel shows. Refetch
// rather than reload: a wall screen must not blank or sign in again.
// `exact` matters — the list key is a prefix of every detail key,
// including the draft an editor may have open.
client?.invalidateQueries({ queryKey: dashboardKeys.all, exact: true })
client?.invalidateQueries({
queryKey: message.dashboard
? dashboardKeys.detail(message.dashboard)
: panelKeys.all,
})
break
}
}
ws.onclose = (event) => {
// A socket we already dropped: its close says nothing about the connection
// we want now.
if (socket !== ws) return
socket = null
liveStore.setConnected(false)
connectionStore.setSocketOpen(false)
if (watchers === 0) return
if (event.code === 1008) {
rejected += 1
if (rejected >= AUTH_ATTEMPTS) {
for (const handler of authHandlers) handler()
return
}
}
// 1013 is the portal saying the installation is not attached — the one
// close code that means "offline" rather than "the socket dropped".
if (event.code === 1013) {
connectionStore.setOffline(null)
}
schedule()
}
}
function release(onAuthFailure?: () => void) {
watchers -= 1
if (onAuthFailure) authHandlers.delete(onAuthFailure)
if (watchers > 0) return
if (timer) {
clearTimeout(timer)
timer = null
}
const ws = socket
socket = null
ws?.close()
liveStore.setConnected(false)
// Its `onclose` is dropped by the guard above, so say so here instead.
connectionStore.setSocketOpen(false)
}
/**
* Keeps one socket open while anything on the page wants live events.
*
* @param onAuthFailure called once the server has refused the token often
* enough to mean the session is gone, so the caller can send the user back to
* the login screen.
*/
export function useFlowSocket(onAuthFailure?: () => void): void {
const queryClient = useQueryClient()
useEffect(() => {
client = queryClient
if (onAuthFailure) authHandlers.add(onAuthFailure)
watchers += 1
connect()
return () => release(onAuthFailure)
}, [onAuthFailure, queryClient])
}