fix(api): self-healing node SSE subscription + live online status #5

Merged
osobh merged 1 commits from fix/node-bridge-auto-reconnect into main 2026-07-03 20:53:50 +00:00
2 changed files with 142 additions and 41 deletions
Showing only changes of commit c5c190c01f - Show all commits
+64 -5
View File
@@ -2,21 +2,30 @@ import { describe, it, expect } from 'vitest'
import { createNodeBridge } from './nodes' import { createNodeBridge } from './nodes'
import type { WsEvent } from './types' import type { WsEvent } from './types'
/** A bridge whose node subscription is driven by the returned `emit`. */ /** A bridge whose node subscription is driven by the returned `emit`/`status`. */
function setup() { function setup(pingUp = true) {
let emit: (e: WsEvent) => void = () => {} let emit: (e: WsEvent) => void = () => {}
let status: (online: boolean) => void = () => {}
const collective: WsEvent[] = [] const collective: WsEvent[] = []
const bridge = createNodeBridge({ const bridge = createNodeBridge({
broadcast: (e) => collective.push(e), broadcast: (e) => collective.push(e),
ping: async () => true, ping: async () => pingUp,
subscribe: (_n, onEvent) => { subscribe: (_n, onEvent, onStatus) => {
emit = onEvent emit = onEvent
status = onStatus
return () => {} return () => {}
}, },
}) })
return { bridge, collective, emit: (e: WsEvent) => emit(e) } return {
bridge,
collective,
emit: (e: WsEvent) => emit(e),
status: (online: boolean) => status(online),
}
} }
const statusEvents = (feed: WsEvent[]) => feed.filter((e) => e.type === 'node:status')
const flash: WsEvent = { type: 'node:activity', teamId: 't1', kind: 'flash', label: 'Flashed to 0x80F0000', ts: 'T' } const flash: WsEvent = { type: 'node:activity', teamId: 't1', kind: 'flash', label: 'Flashed to 0x80F0000', ts: 'T' }
describe('createNodeBridge — per-team activity', () => { describe('createNodeBridge — per-team activity', () => {
@@ -50,3 +59,53 @@ describe('createNodeBridge — per-team activity', () => {
expect(collective).toContainEqual(flash) // admin feed still gets it expect(collective).toContainEqual(flash) // admin feed still gets it
}) })
}) })
describe('createNodeBridge — self-healing status', () => {
it('re-emits node:status offline on a drop and online on reconnect', async () => {
const { bridge, collective, status } = setup()
const seen: WsEvent[] = []
bridge.onTeamActivity('t1', (e) => seen.push(e))
await bridge.register({ teamId: 't1', url: 'u', token: 'k' }) // online (ping)
status(false) // subscription dropped
status(true) // reconnected
expect(statusEvents(collective)).toEqual([
{ type: 'node:status', teamId: 't1', online: true },
{ type: 'node:status', teamId: 't1', online: false },
{ type: 'node:status', teamId: 't1', online: true },
])
// participants get the same recovery signal, not just the collective
expect(statusEvents(seen)).toContainEqual({ type: 'node:status', teamId: 't1', online: true })
expect(statusEvents(seen)).toContainEqual({ type: 'node:status', teamId: 't1', online: false })
// list() reflects the latest state
expect(bridge.list()).toEqual([{ teamId: 't1', url: 'u', online: true }])
})
it('dedups repeated status of the same value (quiet while a board stays down)', async () => {
const { bridge, collective, status } = setup()
await bridge.register({ teamId: 't1', url: 'u', token: 'k' }) // online
status(false)
status(false) // repeated failed reconnect attempts — no new event
status(false)
expect(statusEvents(collective)).toEqual([
{ type: 'node:status', teamId: 't1', online: true },
{ type: 'node:status', teamId: 't1', online: false },
])
})
it('promotes a node that was offline at register once its stream connects', async () => {
const { bridge, collective, status } = setup(false) // ping says offline
await bridge.register({ teamId: 't1', url: 'u', token: 'k' })
expect(bridge.list()).toEqual([{ teamId: 't1', url: 'u', online: false }])
status(true) // the reconnect loop got through
expect(bridge.list()).toEqual([{ teamId: 't1', url: 'u', online: true }])
expect(statusEvents(collective)).toEqual([
{ type: 'node:status', teamId: 't1', online: false },
{ type: 'node:status', teamId: 't1', online: true },
])
})
})
+78 -36
View File
@@ -116,49 +116,82 @@ export async function sendPrompt(node: NodeRef, message: string, agent = 'defaul
}) })
} }
export interface SubscribeOptions {
/** Aborts the whole reconnect loop when fired. */
signal?: AbortSignal
/** Called with `true` on each successful connect, `false` when it drops/fails. */
onStatus?: (online: boolean) => void
minBackoffMs?: number
maxBackoffMs?: number
}
/** Resolve after `ms`, or immediately if `signal` aborts first. */
function sleep(ms: number, signal: AbortSignal): Promise<void> {
return new Promise((resolve) => {
const t = setTimeout(resolve, ms)
signal.addEventListener('abort', () => { clearTimeout(t); resolve() }, { once: true })
})
}
/** /**
* Subscribe to a node's `/api/events` SSE stream, mapping each event to a * Subscribe to a node's `/api/events` SSE stream, mapping each event to a
* WsEvent via `mapNodeEvent` and handing it to `onEvent`. Returns a stop fn. * WsEvent via `mapNodeEvent` and handing it to `onEvent`. Self-healing: the
* stream ends whenever the board drops or its daemon restarts, so we reconnect
* with exponential backoff until aborted — otherwise the participant feed goes
* permanently blind after the first disconnect. `onStatus` reports each
* connect/drop so the bridge can track `online` and re-emit node:status.
* Returns a stop fn.
*/ */
export function subscribeNodeEvents( export function subscribeNodeEvents(
node: NodeRef, node: NodeRef,
onEvent: (e: WsEvent) => void, onEvent: (e: WsEvent) => void,
signal?: AbortSignal, opts: SubscribeOptions = {},
): () => void { ): () => void {
const ctl = new AbortController() const ctl = new AbortController()
if (signal) signal.addEventListener('abort', () => ctl.abort()) if (opts.signal) opts.signal.addEventListener('abort', () => ctl.abort())
const minBackoff = opts.minBackoffMs ?? 1000
const maxBackoff = opts.maxBackoffMs ?? 15000
;(async () => { ;(async () => {
try { let backoff = minBackoff
const res = await fetch(`${node.url}/api/events`, { while (!ctl.signal.aborted) {
headers: { authorization: `Bearer ${node.token}` }, try {
signal: ctl.signal, const res = await fetch(`${node.url}/api/events`, {
}) headers: { authorization: `Bearer ${node.token}` },
if (!res.body) return signal: ctl.signal,
const reader = res.body.getReader() })
const decoder = new TextDecoder() if (!res.ok || !res.body) throw new Error(`events ${res.status}`)
let buf = '' opts.onStatus?.(true)
for (;;) { backoff = minBackoff // healthy connection — reset backoff
const { value, done } = await reader.read() const reader = res.body.getReader()
if (done) break const decoder = new TextDecoder()
buf += decoder.decode(value, { stream: true }) let buf = ''
let nl: number for (;;) {
while ((nl = buf.indexOf('\n')) >= 0) { const { value, done } = await reader.read()
const line = buf.slice(0, nl).trim() if (done) break
buf = buf.slice(nl + 1) buf += decoder.decode(value, { stream: true })
if (!line.startsWith('data:')) continue let nl: number
const payload = line.slice(5).trim() while ((nl = buf.indexOf('\n')) >= 0) {
if (!payload) continue const line = buf.slice(0, nl).trim()
try { buf = buf.slice(nl + 1)
const mapped = mapNodeEvent(node.teamId, JSON.parse(payload)) if (!line.startsWith('data:')) continue
if (mapped) onEvent(mapped) const payload = line.slice(5).trim()
} catch { if (!payload) continue
/* skip malformed line */ try {
const mapped = mapNodeEvent(node.teamId, JSON.parse(payload))
if (mapped) onEvent(mapped)
} catch {
/* skip malformed line */
}
} }
} }
} catch {
/* connect failed or the stream dropped — fall through to reconnect */
} }
} catch { if (ctl.signal.aborted) break
/* stream ended or aborted */ opts.onStatus?.(false)
await sleep(backoff, ctl.signal)
backoff = Math.min(backoff * 2, maxBackoff)
} }
})() })()
@@ -190,7 +223,7 @@ export interface NodeBridgeDeps {
/** Injectable for tests. */ /** Injectable for tests. */
ping?: (n: NodeRef) => Promise<boolean> ping?: (n: NodeRef) => Promise<boolean>
send?: (n: NodeRef, m: string, agent?: string) => Promise<void> send?: (n: NodeRef, m: string, agent?: string) => Promise<void>
subscribe?: (n: NodeRef, onEvent: (e: WsEvent) => void) => () => void subscribe?: (n: NodeRef, onEvent: (e: WsEvent) => void, onStatus: (online: boolean) => void) => () => void
} }
/** /**
@@ -203,7 +236,7 @@ export function createNodeBridge(deps: NodeBridgeDeps): NodeBridge {
const registry = deps.registry ?? createNodeRegistry() const registry = deps.registry ?? createNodeRegistry()
const ping = deps.ping ?? pingNode const ping = deps.ping ?? pingNode
const send = deps.send ?? sendPrompt const send = deps.send ?? sendPrompt
const subscribe = deps.subscribe ?? ((n, on) => subscribeNodeEvents(n, on)) const subscribe = deps.subscribe ?? ((n, on, onStatus) => subscribeNodeEvents(n, on, { onStatus }))
const online = new Map<string, boolean>() const online = new Map<string, boolean>()
const stops = new Map<string, () => void>() const stops = new Map<string, () => void>()
const teamListeners = new Map<string, Set<(e: WsEvent) => void>>() const teamListeners = new Map<string, Set<(e: WsEvent) => void>>()
@@ -213,14 +246,23 @@ export function createNodeBridge(deps: NodeBridgeDeps): NodeBridge {
teamListeners.get(teamId)?.forEach((l) => l(e)) // that team's participant(s) teamListeners.get(teamId)?.forEach((l) => l(e)) // that team's participant(s)
} }
// Emit node:status only on a real change — the reconnect loop reports every
// connect/drop, so dedup keeps the feed quiet while a board stays down.
const setStatus = (teamId: string, up: boolean) => {
if (online.get(teamId) === up) return
online.set(teamId, up)
fanOut(teamId, { type: 'node:status', teamId, online: up })
}
return { return {
async register(ref) { async register(ref) {
stops.get(ref.teamId)?.() stops.get(ref.teamId)?.()
registry.register(ref) registry.register(ref)
const up = await ping(ref) setStatus(ref.teamId, await ping(ref))
online.set(ref.teamId, up) stops.set(
fanOut(ref.teamId, { type: 'node:status', teamId: ref.teamId, online: up }) ref.teamId,
stops.set(ref.teamId, subscribe(ref, (e) => fanOut(ref.teamId, e))) subscribe(ref, (e) => fanOut(ref.teamId, e), (up) => setStatus(ref.teamId, up)),
)
}, },
remove(teamId) { remove(teamId) {
stops.get(teamId)?.() stops.get(teamId)?.()