Files
app/frontend/src/lib/connectionStore.ts
T
stroblme 6c8a101d24 Rename Installation to Instance
Follows the portal: the noun is "instance" everywhere the app says it —
UI strings, CLI output, error details, docs and comments. The wire keys
(`instance_id`, `instance_token`) and the hub route this calls move with it.

An existing cloud.json is adopted rather than refused: without the key
alias the dataclass fails to parse, which the caller swallows and reads as
"never enrolled" instead of "reconnect".

`instance_key` on a node type becomes `target_key`. It means the outside
thing a node points at, which is a different sense of the word, and keeping
both would put two meanings of "instance" in one codebase.
2026-08-31 10:12:01 +02:00

94 lines
3.1 KiB
TypeScript

/**
* Whether the instance this page talks to is reachable right now.
*
* Transport state, kept apart from `liveStore` on purpose: that one holds what
* the engine is doing, this one holds whether we can hear it at all. Only
* meaningful under a portal — a local install talks to itself.
*
* Same hand-rolled external store as liveStore, for the same reason: a
* `useSyncExternalStore` snapshot is the whole requirement.
*/
type Connection = {
offline: boolean
/** When the portal last heard from the instance, epoch ms. */
lastSeen: number | null
}
let state: Connection = { offline: false, lastSeen: null }
/**
* Whether the live socket is up right now.
*
* The socket is the continuous signal; a failed request is only a lagging one.
* Latching `offline` on a single refused request is what used to leave the
* banner up for good: the socket had never dropped, so no reconnect was coming
* to clear it, and nothing else on a quiet page refetches. Reloading was the
* only way out.
*/
let socketOpen = false
const listeners = new Set<() => void>()
function emit(next: Connection) {
// Same values, same object: React re-renders on identity, and a poll that
// keeps confirming "still offline" should not repaint the banner.
if (next.offline === state.offline && next.lastSeen === state.lastSeen) return
state = next
for (const listener of listeners) listener()
}
export const connectionStore = {
setOffline(lastSeen: string | number | null) {
// The socket is up, so the instance is reachable: this was one bad
// answer rather than a lost tunnel, and the banner would have nothing to
// take it back down again.
if (socketOpen) return
const at =
typeof lastSeen === "string"
? Date.parse(lastSeen)
: typeof lastSeen === "number"
? lastSeen
: null
emit({ offline: true, lastSeen: Number.isNaN(at) ? null : at })
},
setOnline() {
emit({ offline: false, lastSeen: null })
},
/**
* The live socket opened or closed.
*
* An open socket is proof the instance can be heard, so it also clears
* the banner — and since the socket reconnects on its own, that is the one
* release the offline state can always count on.
*/
setSocketOpen(open: boolean) {
socketOpen = open
if (open) emit({ offline: false, lastSeen: null })
},
subscribe(listener: () => void) {
listeners.add(listener)
return () => listeners.delete(listener)
},
snapshot(): Connection {
return state
},
}
/**
* The proxy's offline answer: a 503 carrying `{offline, last_seen}`.
*
* Told apart from every other 503 by that body, so an instance that is
* merely busy is not reported as unreachable.
*/
export function offlineDetail(
error: unknown,
): { lastSeen: string | null } | null {
const body = (error as { body?: unknown; status?: number })?.body
const status = (error as { status?: number })?.status
if ((status !== 503 && status !== 502) || typeof body !== "object" || !body) {
return null
}
const detail = body as { offline?: boolean; last_seen?: string | null }
if (detail.offline !== true) return null
return { lastSeen: detail.last_seen ?? null }
}