/** * C-015 + P-006/008/009: Gateway Connection Lifecycle * * - Register device with Gateway on startup * - Heartbeat keepalive every N seconds * - Exponential backoff reconnection on disconnect * - Connectivity check before registration */ import { type DeviceConfig } from "@yuzu-gca/shared"; export interface ConnectionState { status: "disconnected" | "connecting" | "connected" | "reconnecting"; lastHeartbeat: number; reconnectAttempt: number; registeredAt: number | null; } /** * Check if the Gateway is reachable. * Returns true if a HEAD request to gateway.url succeeds. */ export async function checkConnectivity(gatewayUrl: string): Promise { try { const resp = await fetch(`${gatewayUrl}/health`, { method: "HEAD", signal: AbortSignal.timeout(5000), }); return resp.ok; } catch { return false; } } /** * Register this device with the Gateway. * POST /devices/register with device identity and capabilities. * Returns the assigned device token on success. */ export async function registerWithGateway( gatewayUrl: string, config: DeviceConfig, ): Promise<{ token: string } | null> { try { const resp = await fetch(`${gatewayUrl}/devices/register`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id: config.identity.id, name: config.identity.name, platform: config.identity.platform, arch: config.identity.arch, hostname: config.identity.hostname, capabilities: config.capabilities, transport: config.gateway.transport, }), signal: AbortSignal.timeout(10000), }); if (!resp.ok) { console.error(`[Gateway] Registration failed: HTTP ${resp.status}`); return null; } const data = await resp.json() as { token: string }; console.log(`[Gateway] Registered as ${config.identity.name} (${config.identity.id})`); return { token: data.token }; } catch (err) { console.error(`[Gateway] Registration error:`, (err as Error).message); return null; } } /** * Send a heartbeat to the Gateway. * POST /devices/:id/heartbeat * Returns true if Gateway acknowledged. */ export async function sendHeartbeat( gatewayUrl: string, deviceId: string, token: string, ): Promise { try { const resp = await fetch(`${gatewayUrl}/devices/${deviceId}/heartbeat`, { method: "POST", headers: { "Content-Type": "application/json", "Authorization": `Bearer ${token}`, }, body: JSON.stringify({ timestamp: Date.now() }), signal: AbortSignal.timeout(5000), }); return resp.ok; } catch { return false; } } /** * Unregister from Gateway. * DELETE /devices/:id */ export async function unregisterFromGateway( gatewayUrl: string, deviceId: string, token: string, ): Promise { try { await fetch(`${gatewayUrl}/devices/${deviceId}`, { method: "DELETE", headers: { "Authorization": `Bearer ${token}` }, signal: AbortSignal.timeout(5000), }); } catch { // Best effort } } /** * Compute exponential backoff delay with jitter. */ export function backoffDelay(attempt: number, maxMs: number = 30000): number { const base = Math.min(1000 * Math.pow(2, attempt), maxMs); const jitter = Math.random() * base * 0.3; // 0-30% jitter return base + jitter; } /** * Connection manager — handles the full lifecycle: * check → register → heartbeat loop → disconnect → reconnect with backoff */ export class ConnectionManager { private state: ConnectionState = { status: "disconnected", lastHeartbeat: 0, reconnectAttempt: 0, registeredAt: null, }; private heartbeatTimer: ReturnType | null = null; private token: string = ""; private stopped: boolean = false; constructor( private config: DeviceConfig, private onStateChange?: (state: ConnectionState) => void, ) {} get currentState(): ConnectionState { return { ...this.state }; } /** Start the connection lifecycle (check → register → heartbeat) */ async start(): Promise { this.stopped = false; await this.connect(); } /** Stop everything */ stop(): void { this.stopped = true; this.clearHeartbeat(); if (this.token) { unregisterFromGateway(this.config.gateway.url, this.config.identity.id, this.token) .catch(() => {}); } this.setState({ status: "disconnected", registeredAt: null }); } private async connect(): Promise { if (this.stopped) return; this.setState({ status: "connecting" }); const reachable = await checkConnectivity(this.config.gateway.url); if (!reachable) { console.warn("[Gateway] Unreachable, retrying..."); this.setState({ status: "reconnecting" }); this.scheduleReconnect(); return; } const result = await registerWithGateway(this.config.gateway.url, this.config); if (!result) { this.setState({ status: "reconnecting" }); this.scheduleReconnect(); return; } this.token = result.token; this.state.reconnectAttempt = 0; this.setState({ status: "connected", registeredAt: Date.now() }); this.startHeartbeat(); } private startHeartbeat(): void { this.clearHeartbeat(); this.heartbeatTimer = setInterval(async () => { if (this.stopped) return; const ok = await sendHeartbeat( this.config.gateway.url, this.config.identity.id, this.token, ); this.state.lastHeartbeat = Date.now(); if (!ok && !this.stopped) { console.warn("[Gateway] Heartbeat failed, reconnecting..."); this.clearHeartbeat(); this.setState({ status: "reconnecting" }); this.scheduleReconnect(); } }, this.config.gateway.heartbeatInterval); } private clearHeartbeat(): void { if (this.heartbeatTimer) { clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; } } private scheduleReconnect(): void { if (this.stopped) return; const delay = backoffDelay( this.state.reconnectAttempt++, this.config.gateway.maxReconnectDelay, ); console.log(`[Gateway] Reconnecting in ${Math.round(delay)}ms (attempt ${this.state.reconnectAttempt})`); setTimeout(() => this.connect(), delay); } private setState(partial: Partial): void { this.state = { ...this.state, ...partial }; this.onStateChange?.(this.currentState); } }