Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com> Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Dax Raad <d@ironbay.co> Co-authored-by: Dax <mail@thdxr.com> Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com> Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Co-authored-by: Nabs <nabil@instafork.com> Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com> Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Co-authored-by: Brendan Allan <git@brendonovich.dev> Co-authored-by: Victor Navarro <vn4varro@gmail.com> Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com> Co-authored-by: AidenGeunGeun <eastlandwyvern@gmail.com> Co-authored-by: Mark <geraint0923@users.noreply.github.com> Co-authored-by: Aiden Cline <aidenpcline@gmail.com> Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com> Co-authored-by: opencode <opencode@sst.dev> Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com> Co-authored-by: Jay <air@live.ca> Co-authored-by: Jay <53023+jayair@users.noreply.github.com> Co-authored-by: BB84 <110078428+BB-84C@users.noreply.github.com> Co-authored-by: Dustin Deus <deusdustin@gmail.com> Co-authored-by: Frank <frank@anoma.ly> Co-authored-by: Jack <jack@anoma.ly> Co-authored-by: Sebastian <hasta84@gmail.com> Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com> Co-authored-by: Test User <test@test.com> Co-authored-by: Simon Klee <hello@simonklee.dk> Co-authored-by: Rahul A Mistry <149420892+ProdigyRahul@users.noreply.github.com> Co-authored-by: Qiping Li <liqiping1991@gmail.com> Co-authored-by: liqiping <liqiping@msh.team> Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Co-authored-by: Matthias Reso <13337103+mreso@users.noreply.github.com> Co-authored-by: tobwen <1864057+tobwen@users.noreply.github.com> Co-authored-by: Daniel Polito <danielbpolito@gmail.com> Co-authored-by: opencode <noreply@opencode.ai>
172 lines
5.7 KiB
TypeScript
172 lines
5.7 KiB
TypeScript
import { usePlatform } from "@/context/platform"
|
|
import { ServerConnection } from "@/context/server"
|
|
import { authTokenFromCredentials, createSdkForServer } from "./server"
|
|
import { ClientError, OpenCode } from "@opencode-ai/client"
|
|
import { Accessor, createEffect, onCleanup } from "solid-js"
|
|
import { createStore, reconcile } from "solid-js/store"
|
|
|
|
export type ServerHealth = { healthy: boolean; version?: string }
|
|
|
|
interface CheckServerHealthOptions {
|
|
timeoutMs?: number
|
|
signal?: AbortSignal
|
|
retryCount?: number
|
|
retryDelayMs?: number
|
|
}
|
|
|
|
const defaultTimeoutMs = 30_000
|
|
const defaultRetryCount = 2
|
|
const defaultRetryDelayMs = 100
|
|
const cacheMs = 750
|
|
const healthCache = new Map<
|
|
string,
|
|
{ at: number; done: boolean; fetch: typeof globalThis.fetch; promise: Promise<ServerHealth> }
|
|
>()
|
|
|
|
function cacheKey(server: ServerConnection.HttpBase) {
|
|
return `${server.url}\n${server.username ?? ""}\n${server.password ?? ""}`
|
|
}
|
|
|
|
function timeoutSignal(timeoutMs: number) {
|
|
const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout
|
|
if (timeout) {
|
|
try {
|
|
return {
|
|
signal: timeout.call(AbortSignal, timeoutMs),
|
|
clear: undefined as (() => void) | undefined,
|
|
}
|
|
} catch {}
|
|
}
|
|
const controller = new AbortController()
|
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
return { signal: controller.signal, clear: () => clearTimeout(timer) }
|
|
}
|
|
|
|
function wait(ms: number, signal?: AbortSignal) {
|
|
return new Promise<void>((resolve, reject) => {
|
|
if (signal?.aborted) {
|
|
reject(new DOMException("Aborted", "AbortError"))
|
|
return
|
|
}
|
|
const timer = setTimeout(() => {
|
|
signal?.removeEventListener("abort", onAbort)
|
|
resolve()
|
|
}, ms)
|
|
const onAbort = () => {
|
|
clearTimeout(timer)
|
|
reject(new DOMException("Aborted", "AbortError"))
|
|
}
|
|
signal?.addEventListener("abort", onAbort, { once: true })
|
|
})
|
|
}
|
|
|
|
function retryable(error: unknown, signal?: AbortSignal) {
|
|
if (signal?.aborted) return false
|
|
if (error instanceof ClientError) return error.reason === "Transport"
|
|
if (!(error instanceof Error)) return false
|
|
if (error.name === "AbortError" || error.name === "TimeoutError") return false
|
|
if (error instanceof TypeError) return true
|
|
return /network|fetch|econnreset|econnrefused|enotfound|timedout/i.test(error.message)
|
|
}
|
|
|
|
export async function checkServerHealth(
|
|
server: ServerConnection.HttpBase,
|
|
fetch: typeof globalThis.fetch,
|
|
opts?: CheckServerHealthOptions,
|
|
): Promise<ServerHealth> {
|
|
const timeout = opts?.signal ? undefined : timeoutSignal(opts?.timeoutMs ?? defaultTimeoutMs)
|
|
const signal = opts?.signal ?? timeout?.signal
|
|
const retryCount = opts?.retryCount ?? defaultRetryCount
|
|
const retryDelayMs = opts?.retryDelayMs ?? defaultRetryDelayMs
|
|
const next = (count: number, error: unknown) => {
|
|
if (count >= retryCount || !retryable(error, signal)) return Promise.resolve({ healthy: false } as const)
|
|
return wait(retryDelayMs * (count + 1), signal)
|
|
.then(() => attempt(count + 1))
|
|
.catch(() => ({ healthy: false }))
|
|
}
|
|
const attempt = async (count: number): Promise<ServerHealth> => {
|
|
const current = await OpenCode.make({
|
|
baseUrl: server.url,
|
|
fetch,
|
|
headers: server.password
|
|
? {
|
|
Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`,
|
|
}
|
|
: undefined,
|
|
})
|
|
.health.get({ signal })
|
|
.then((x) =>
|
|
typeof x.healthy === "boolean"
|
|
? { data: { healthy: x.healthy, version: x.version } }
|
|
: { error: new Error("Invalid health response") },
|
|
)
|
|
.catch((error) => ({ error }))
|
|
if ("data" in current && current.data) return current.data
|
|
if (signal?.aborted) return { healthy: false }
|
|
|
|
return createSdkForServer({ server, fetch, signal })
|
|
.global.health()
|
|
.then((x) => (x.error ? next(count, x.error) : { healthy: x.data?.healthy === true, version: x.data?.version }))
|
|
.catch((error) => next(count, error))
|
|
}
|
|
return attempt(0).finally(() => timeout?.clear?.())
|
|
}
|
|
|
|
const pollMs = 10_000
|
|
|
|
export function useCheckServerHealth() {
|
|
const platform = usePlatform()
|
|
const fetcher = platform.fetch ?? globalThis.fetch
|
|
|
|
return (http: ServerConnection.HttpBase) => {
|
|
const key = cacheKey(http)
|
|
const hit = healthCache.get(key)
|
|
const now = Date.now()
|
|
if (hit && hit.fetch === fetcher && (!hit.done || now - hit.at < cacheMs)) return hit.promise
|
|
const promise = checkServerHealth(http, fetcher).finally(() => {
|
|
const next = healthCache.get(key)
|
|
if (!next || next.promise !== promise) return
|
|
next.done = true
|
|
next.at = Date.now()
|
|
})
|
|
healthCache.set(key, { at: now, done: false, fetch: fetcher, promise })
|
|
return promise
|
|
}
|
|
}
|
|
|
|
export const useServerHealth = (servers: Accessor<ServerConnection.Any[]>, enabled: Accessor<boolean>) => {
|
|
const checkServerHealth = useCheckServerHealth()
|
|
const [status, setStatus] = createStore({} as Record<ServerConnection.Key, ServerHealth | undefined>)
|
|
|
|
createEffect(() => {
|
|
if (!enabled()) {
|
|
setStatus(reconcile({}))
|
|
return
|
|
}
|
|
const list = servers()
|
|
let dead = false
|
|
|
|
const refresh = async () => {
|
|
const results: Record<string, ServerHealth> = {}
|
|
await Promise.all(
|
|
list.map(async (conn) => {
|
|
const key = ServerConnection.key(conn)
|
|
const result = await checkServerHealth(conn.http)
|
|
results[key] = result
|
|
if (!dead) setStatus(key, result)
|
|
}),
|
|
)
|
|
if (dead) return
|
|
setStatus(reconcile(results))
|
|
}
|
|
|
|
void refresh()
|
|
const id = setInterval(() => void refresh(), pollMs)
|
|
onCleanup(() => {
|
|
dead = true
|
|
clearInterval(id)
|
|
})
|
|
})
|
|
|
|
return status
|
|
}
|