mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-07 11:35:53 +00:00
refactor: simplify HQ architecture with active health checking
- Remove heartbeat mechanism from HQClient - now just registers and forgets - Update RemoteRegistry to actively check remote health via GET /api/sessions - Remove /api/remotes/:remoteId/heartbeat endpoint as it's no longer needed - Pass password to RemoteRegistry for authenticated health checks - Remote servers no longer need to maintain connection to HQ after registration - HQ checks remote health every 15 seconds with 5 second timeout This simplifies the architecture - remotes just tell HQ they exist, and HQ is responsible for monitoring their health status. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
42c1dff89a
commit
c9a08a415a
3 changed files with 65 additions and 138 deletions
|
|
@ -5,26 +5,18 @@ export class HQClient {
|
|||
private readonly hqUrl: string;
|
||||
private readonly remoteId: string;
|
||||
private readonly remoteName: string;
|
||||
private token: string;
|
||||
private heartbeatInterval: NodeJS.Timeout | null = null;
|
||||
private readonly token: string;
|
||||
private readonly password: string;
|
||||
private registrationRetryTimeout: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(hqUrl: string, password: string) {
|
||||
this.hqUrl = hqUrl;
|
||||
this.remoteId = uuidv4();
|
||||
this.remoteName = `${os.hostname()}-${process.pid}`;
|
||||
this.token = this.generateToken();
|
||||
|
||||
// Store password for future use
|
||||
this.token = uuidv4();
|
||||
this.password = password;
|
||||
}
|
||||
|
||||
private password: string;
|
||||
|
||||
private generateToken(): string {
|
||||
return uuidv4();
|
||||
}
|
||||
|
||||
async register(): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(`${this.hqUrl}/api/remotes/register`, {
|
||||
|
|
@ -46,7 +38,8 @@ export class HQClient {
|
|||
}
|
||||
|
||||
console.log(`Successfully registered with HQ at ${this.hqUrl}`);
|
||||
this.startHeartbeat();
|
||||
console.log(`Remote ID: ${this.remoteId}`);
|
||||
console.log(`Token: ${this.token}`);
|
||||
} catch (error) {
|
||||
console.error('Failed to register with HQ:', error);
|
||||
// Retry registration after 5 seconds
|
||||
|
|
@ -54,70 +47,7 @@ export class HQClient {
|
|||
}
|
||||
}
|
||||
|
||||
private async sendHeartbeat(): Promise<void> {
|
||||
try {
|
||||
// Get session count from the session manager
|
||||
const sessionCount = await this.getSessionCount();
|
||||
|
||||
const response = await fetch(`${this.hqUrl}/api/remotes/${this.remoteId}/heartbeat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${this.token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
sessionCount,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error('Heartbeat failed:', response.statusText);
|
||||
// Re-register if heartbeat fails
|
||||
this.stopHeartbeat();
|
||||
await this.register();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to send heartbeat:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private startHeartbeat(): void {
|
||||
// Send heartbeat every 15 seconds
|
||||
this.heartbeatInterval = setInterval(() => {
|
||||
this.sendHeartbeat();
|
||||
}, 15000);
|
||||
|
||||
// Send first heartbeat immediately
|
||||
this.sendHeartbeat();
|
||||
}
|
||||
|
||||
private stopHeartbeat(): void {
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval);
|
||||
this.heartbeatInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
private async getSessionCount(): Promise<number> {
|
||||
try {
|
||||
const response = await fetch(`http://localhost:${process.env.PORT || 4020}/api/sessions`, {
|
||||
headers: {
|
||||
Authorization: `Basic ${Buffer.from(`user:${this.password}`).toString('base64')}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const sessions = await response.json();
|
||||
return Array.isArray(sessions) ? sessions.length : 0;
|
||||
}
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.stopHeartbeat();
|
||||
if (this.registrationRetryTimeout) {
|
||||
clearTimeout(this.registrationRetryTimeout);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,11 +11,14 @@ export interface RemoteServer {
|
|||
|
||||
export class RemoteRegistry {
|
||||
private remotes: Map<string, RemoteServer> = new Map();
|
||||
private heartbeatInterval: NodeJS.Timeout | null = null;
|
||||
private readonly HEARTBEAT_TIMEOUT = 30000; // 30 seconds
|
||||
private healthCheckInterval: NodeJS.Timeout | null = null;
|
||||
private readonly HEALTH_CHECK_INTERVAL = 15000; // Check every 15 seconds
|
||||
private readonly HEALTH_CHECK_TIMEOUT = 5000; // 5 second timeout per check
|
||||
private password: string | null;
|
||||
|
||||
constructor() {
|
||||
this.startHeartbeatChecker();
|
||||
constructor(password: string | null = null) {
|
||||
this.password = password;
|
||||
this.startHealthChecker();
|
||||
}
|
||||
|
||||
register(remote: Omit<RemoteServer, 'registeredAt' | 'lastHeartbeat' | 'status'>): RemoteServer {
|
||||
|
|
@ -30,6 +33,9 @@ export class RemoteRegistry {
|
|||
this.remotes.set(remote.id, registeredRemote);
|
||||
console.log(`Remote registered: ${remote.name} (${remote.id}) from ${remote.url}`);
|
||||
|
||||
// Immediately check health of new remote
|
||||
this.checkRemoteHealth(registeredRemote);
|
||||
|
||||
return registeredRemote;
|
||||
}
|
||||
|
||||
|
|
@ -42,17 +48,6 @@ export class RemoteRegistry {
|
|||
return false;
|
||||
}
|
||||
|
||||
updateHeartbeat(remoteId: string, sessionCount: number): boolean {
|
||||
const remote = this.remotes.get(remoteId);
|
||||
if (remote) {
|
||||
remote.lastHeartbeat = new Date();
|
||||
remote.sessionCount = sessionCount;
|
||||
remote.status = 'online';
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
getRemote(remoteId: string): RemoteServer | undefined {
|
||||
return this.remotes.get(remoteId);
|
||||
}
|
||||
|
|
@ -69,24 +64,60 @@ export class RemoteRegistry {
|
|||
return this.getAllRemotes().filter((r) => r.status === 'online');
|
||||
}
|
||||
|
||||
private startHeartbeatChecker() {
|
||||
this.heartbeatInterval = setInterval(() => {
|
||||
const now = Date.now();
|
||||
private async checkRemoteHealth(remote: RemoteServer): Promise<void> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), this.HEALTH_CHECK_TIMEOUT);
|
||||
|
||||
for (const remote of this.remotes.values()) {
|
||||
const timeSinceLastHeartbeat = now - remote.lastHeartbeat.getTime();
|
||||
|
||||
if (timeSinceLastHeartbeat > this.HEARTBEAT_TIMEOUT && remote.status === 'online') {
|
||||
remote.status = 'offline';
|
||||
console.log(`Remote went offline: ${remote.name} (${remote.id})`);
|
||||
}
|
||||
const headers: Record<string, string> = {};
|
||||
if (this.password) {
|
||||
headers['Authorization'] =
|
||||
`Basic ${Buffer.from(`user:${this.password}`).toString('base64')}`;
|
||||
}
|
||||
}, 10000); // Check every 10 seconds
|
||||
|
||||
const response = await fetch(`${remote.url}/api/sessions`, {
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (response.ok) {
|
||||
const sessions = await response.json();
|
||||
remote.lastHeartbeat = new Date();
|
||||
remote.sessionCount = Array.isArray(sessions) ? sessions.length : 0;
|
||||
|
||||
if (remote.status !== 'online') {
|
||||
remote.status = 'online';
|
||||
console.log(`Remote came online: ${remote.name} (${remote.id})`);
|
||||
}
|
||||
} else {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (remote.status !== 'offline') {
|
||||
remote.status = 'offline';
|
||||
console.log(`Remote went offline: ${remote.name} (${remote.id}) - ${error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private startHealthChecker() {
|
||||
this.healthCheckInterval = setInterval(() => {
|
||||
// Check all remotes in parallel
|
||||
const healthChecks = Array.from(this.remotes.values()).map((remote) =>
|
||||
this.checkRemoteHealth(remote)
|
||||
);
|
||||
|
||||
Promise.all(healthChecks).catch((err) => {
|
||||
console.error('Error in health checks:', err);
|
||||
});
|
||||
}, this.HEALTH_CHECK_INTERVAL);
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval);
|
||||
if (this.healthCheckInterval) {
|
||||
clearInterval(this.healthCheckInterval);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ let remoteRegistry: RemoteRegistry | null = null;
|
|||
let hqClient: HQClient | null = null;
|
||||
|
||||
if (isHQMode) {
|
||||
remoteRegistry = new RemoteRegistry();
|
||||
remoteRegistry = new RemoteRegistry(basicAuthPassword);
|
||||
console.log(`${GREEN}Running in HQ mode${RESET}`);
|
||||
}
|
||||
|
||||
|
|
@ -448,40 +448,6 @@ app.post('/api/remotes/register', (req, res) => {
|
|||
res.json({ success: true, remote: { id: remote.id, name: remote.name } });
|
||||
});
|
||||
|
||||
// Heartbeat from remote server
|
||||
app.post('/api/remotes/:remoteId/heartbeat', (req, res) => {
|
||||
if (!isHQMode || !remoteRegistry) {
|
||||
return res.status(404).json({ error: 'HQ mode not enabled' });
|
||||
}
|
||||
|
||||
const { remoteId } = req.params;
|
||||
const { sessionCount } = req.body;
|
||||
const authHeader = req.headers.authorization;
|
||||
|
||||
// Verify token
|
||||
const remote = remoteRegistry.getRemote(remoteId);
|
||||
if (!remote) {
|
||||
return res.status(404).json({ error: 'Remote not found' });
|
||||
}
|
||||
|
||||
if (!authHeader || !authHeader.startsWith('Bearer ')) {
|
||||
return res.status(401).json({ error: 'Missing authorization' });
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7);
|
||||
if (token !== remote.token) {
|
||||
return res.status(401).json({ error: 'Invalid token' });
|
||||
}
|
||||
|
||||
const success = remoteRegistry.updateHeartbeat(remoteId, sessionCount || 0);
|
||||
|
||||
if (success) {
|
||||
res.json({ success: true });
|
||||
} else {
|
||||
res.status(404).json({ error: 'Remote not found' });
|
||||
}
|
||||
});
|
||||
|
||||
// Unregister remote
|
||||
app.delete('/api/remotes/:remoteId', (req, res) => {
|
||||
if (!isHQMode || !remoteRegistry) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue