Files
technitium-console/hub/upstream.ts
T
DenozordecandClaude Opus 5.5 5f29a438c9 Hub stage 2: read-only Technitium-compatible server over several servers
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]>
2026-10-30 19:04:41 +07:00

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('***')
}
}