/** * Reading a hosted child's `/status` — the one window the host has onto a node it * runs: its peer id and addresses before the claim, the claim itself, the party * and the owner key after it, and its removal by an owner. Shared by * `HostedNodeService.claimDetails` and the status watcher. */ // The child's `/status` is typed against the CLI's own answer, so a field the CLI // renames is a compile error here rather than a watcher that never sees a claim. import type { HealthStatus } from '@serfab/cadre-cli'; import { HostedNodeError } from './types.js'; /** The party an unclaimed child's config names; the claim replaces it. */ export const PLACEHOLDER_PARTY = 'unclaimed'; /** The `/status` URL for a spawn's `/health` URL (same server and port). */ export function statusUrlFor(healthEndpoint: string): string { const url = new URL(healthEndpoint); url.pathname = '/status'; return url.toString(); } /** A `/status` answer from a node that has its peer identity. */ export type NodeStatus = HealthStatus & { peerId: string }; /** * A `/status` answer from a child an owner removed from its cadre. The child stopped its node * and stays up only to report this, so it has no peer identity. */ export type RemovedNodeStatus = HealthStatus & { node: { removed: NonNullable } }; /** * The child's `/status`, fresh every call. Throws `node_unavailable` while the child * does not answer, answers with an error, or has no peer identity yet — every one of * which a caller retries. */ export async function readNodeStatus(statusEndpoint: string): Promise { return requirePeerIdentity(await fetchNodeStatus(statusEndpoint)); } /** * {@link readNodeStatus} for the watcher, which also has to hear a removal: a child reporting * one is answered as it is rather than refused for having no peer identity. */ export async function readWatchedNodeStatus(statusEndpoint: string): Promise { const status = await fetchNodeStatus(statusEndpoint); return reportsRemoval(status) ? status : requirePeerIdentity(status); } /** Whether the child reports that an owner removed it from its cadre. */ export function reportsRemoval(status: HealthStatus): status is RemovedNodeStatus { return Boolean(status.node.removed); } async function fetchNodeStatus(statusEndpoint: string): Promise { let res: Response; try { res = await fetch(statusEndpoint); } catch (err) { throw new HostedNodeError('node_unavailable', `Hosted node unreachable: ${errorMessage(err)}`); } if (!res.ok) { throw new HostedNodeError('node_unavailable', `Hosted node /status returned ${res.status}`); } try { return (await res.json()) as HealthStatus; } catch (err) { throw new HostedNodeError('node_unavailable', `Hosted node /status is not JSON: ${errorMessage(err)}`); } } function requirePeerIdentity(status: HealthStatus): NodeStatus { if (!status.peerId || !status.multiaddrs?.length) { throw new HostedNodeError('node_unavailable', 'Hosted node has no peer identity yet'); } return status as NodeStatus; } /** * Whether `/status` reports a finished claim: a claim on record AND the party it * named. `claim` flips to `claimed` the moment the node records the claim, but the * node then restarts in-process into the claimed party, and until that restart * finishes `partyId` is still the placeholder (or empty while the node object is * being rebuilt). Writing `joined` on the flag alone would record the placeholder * as the node's cadre. */ export function reportsClaim(status: NodeStatus): status is NodeStatus & { node: { claimedBy: string } } { return status.node.claim === 'claimed' && status.node.claimedBy !== undefined && status.node.partyId !== '' && status.node.partyId !== PLACEHOLDER_PARTY; } export function errorMessage(err: unknown): string { return err instanceof Error ? err.message : String(err); }