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 <noreply@anthropic.com>
This commit is contained in:
1 parent
9df2c2b908
commit
5f29a438c9
8 files changed
+980
-7
No files matched your search
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
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('***')
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user