232 lines
6.4 KiB
TypeScript
232 lines
6.4 KiB
TypeScript
/**
|
|
* 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<boolean> {
|
|
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<boolean> {
|
|
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<void> {
|
|
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<typeof setInterval> | 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<void> {
|
|
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<void> {
|
|
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<ConnectionState>): void {
|
|
this.state = { ...this.state, ...partial };
|
|
this.onStateChange?.(this.currentState);
|
|
}
|
|
}
|