node:http server that serves the console build and a /api/* the console accepts: its own login and session (clusterInitialized, the servers as nodes, hub flag, Dashboard and Logs only), node=<id> forwarded with that server's token, no node or cluster merged (stats, top lists, query logs, apps), every write refused with 403, a short response cache, and an unreachable server reported as hubWarnings. The dev mock takes --port and --scale so two of them differ. Vitest skips .claude/ worktrees. Co-Authored-By: Claude Opus 5.5 <[email protected]>
98 lines
3.6 KiB
TypeScript
98 lines
3.6 KiB
TypeScript
/*
|
|
Calls to the real Technitium servers: `GET <url>/api/<path>?<params>&token=<token>`.
|
|
Successful answers are cached for a few seconds, so a dashboard that refreshes every
|
|
minute (and several people looking at it) does not load the servers.
|
|
*/
|
|
import type { HubConfig, HubServer } from './config.ts'
|
|
|
|
/*
|
|
`unreachable`: no answer (network, timeout, not JSON). `rejected`: the server answered
|
|
with a status other than `ok`. The hub never relays the server's `invalid-token`, or
|
|
the console would sign the hub's own user out because a hub token expired.
|
|
*/
|
|
export class UpstreamError extends Error {
|
|
kind: 'unreachable' | 'rejected'
|
|
constructor(kind: 'unreachable' | 'rejected', message: string) {
|
|
super(message)
|
|
this.name = 'UpstreamError'
|
|
this.kind = kind
|
|
}
|
|
}
|
|
|
|
export type Params = Record<string, string>
|
|
|
|
interface Cached {
|
|
until: number
|
|
response: unknown
|
|
}
|
|
|
|
const MAX_CACHED = 500
|
|
|
|
export class Upstream {
|
|
#config: HubConfig
|
|
#fetch: typeof fetch
|
|
#cache = new Map<string, Cached>()
|
|
#now: () => number
|
|
|
|
constructor(config: HubConfig, fetchImpl: typeof fetch = fetch, now: () => number = Date.now) {
|
|
this.#config = config
|
|
this.#fetch = fetchImpl
|
|
this.#now = now
|
|
}
|
|
|
|
/** The `response` of the server's envelope, or an `UpstreamError`. */
|
|
async call(server: HubServer, path: string, params: Params = {}): Promise<unknown> {
|
|
const sorted = Object.entries(params).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
|
const key = `${server.id}\n${path}\n${new URLSearchParams(sorted).toString()}`
|
|
const hit = this.#cache.get(key)
|
|
if (hit !== undefined && hit.until > this.#now()) return hit.response
|
|
|
|
const query = new URLSearchParams(sorted)
|
|
query.set('token', server.token)
|
|
const controller = new AbortController()
|
|
const timer = setTimeout(() => controller.abort(), this.#config.timeoutMs)
|
|
|
|
let envelope: { status?: string; response?: unknown; errorMessage?: string }
|
|
try {
|
|
const res = await this.#fetch(`${server.url}/api/${path}?${query.toString()}`, {
|
|
signal: controller.signal,
|
|
headers: { accept: 'application/json' },
|
|
})
|
|
envelope = (await res.json()) as typeof envelope
|
|
} catch (e) {
|
|
throw new UpstreamError('unreachable', this.#describe(e, server))
|
|
} finally {
|
|
clearTimeout(timer)
|
|
}
|
|
|
|
if (envelope?.status !== 'ok') {
|
|
const why =
|
|
envelope?.status === 'invalid-token'
|
|
? 'its API token was refused'
|
|
: (envelope?.errorMessage ?? `status ${String(envelope?.status)}`)
|
|
throw new UpstreamError('rejected', why)
|
|
}
|
|
|
|
if (this.#config.cacheSeconds > 0) {
|
|
if (this.#cache.size >= MAX_CACHED) this.#prune()
|
|
this.#cache.set(key, { until: this.#now() + this.#config.cacheSeconds * 1000, response: envelope.response })
|
|
}
|
|
return envelope.response
|
|
}
|
|
|
|
#prune(): void {
|
|
const now = this.#now()
|
|
for (const [key, entry] of this.#cache) if (entry.until <= now) this.#cache.delete(key)
|
|
// Still full of live entries: drop the oldest inserted.
|
|
while (this.#cache.size >= MAX_CACHED) this.#cache.delete(this.#cache.keys().next().value as string)
|
|
}
|
|
|
|
/* A short reason without anything that could carry the token. */
|
|
#describe(e: unknown, server: HubServer): string {
|
|
if (e instanceof Error && e.name === 'AbortError') return `timed out after ${this.#config.timeoutMs} ms`
|
|
const cause = e instanceof Error ? (e.cause as { code?: unknown } | undefined) : undefined
|
|
const text = typeof cause?.code === 'string' ? cause.code : e instanceof Error ? e.message : 'request failed'
|
|
return text.split(server.token).join('***')
|
|
}
|
|
}
|