mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-04-27 15:17:38 +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 hqUrl: string;
|
||||||
private readonly remoteId: string;
|
private readonly remoteId: string;
|
||||||
private readonly remoteName: string;
|
private readonly remoteName: string;
|
||||||
private token: string;
|
private readonly token: string;
|
||||||
private heartbeatInterval: NodeJS.Timeout | null = null;
|
private readonly password: string;
|
||||||
private registrationRetryTimeout: NodeJS.Timeout | null = null;
|
private registrationRetryTimeout: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
constructor(hqUrl: string, password: string) {
|
constructor(hqUrl: string, password: string) {
|
||||||
this.hqUrl = hqUrl;
|
this.hqUrl = hqUrl;
|
||||||
this.remoteId = uuidv4();
|
this.remoteId = uuidv4();
|
||||||
this.remoteName = `${os.hostname()}-${process.pid}`;
|
this.remoteName = `${os.hostname()}-${process.pid}`;
|
||||||
this.token = this.generateToken();
|
this.token = uuidv4();
|
||||||
|
|
||||||
// Store password for future use
|
|
||||||
this.password = password;
|
this.password = password;
|
||||||
}
|
}
|
||||||
|
|
||||||
private password: string;
|
|
||||||
|
|
||||||
private generateToken(): string {
|
|
||||||
return uuidv4();
|
|
||||||
}
|
|
||||||
|
|
||||||
async register(): Promise<void> {
|
async register(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${this.hqUrl}/api/remotes/register`, {
|
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}`);
|
console.log(`Successfully registered with HQ at ${this.hqUrl}`);
|
||||||
this.startHeartbeat();
|
console.log(`Remote ID: ${this.remoteId}`);
|
||||||
|
console.log(`Token: ${this.token}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to register with HQ:', error);
|
console.error('Failed to register with HQ:', error);
|
||||||
// Retry registration after 5 seconds
|
// 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 {
|
destroy(): void {
|
||||||
this.stopHeartbeat();
|
|
||||||
if (this.registrationRetryTimeout) {
|
if (this.registrationRetryTimeout) {
|
||||||
clearTimeout(this.registrationRetryTimeout);
|
clearTimeout(this.registrationRetryTimeout);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,14 @@ export interface RemoteServer {
|
||||||
|
|
||||||
export class RemoteRegistry {
|
export class RemoteRegistry {
|
||||||
private remotes: Map<string, RemoteServer> = new Map();
|
private remotes: Map<string, RemoteServer> = new Map();
|
||||||
private heartbeatInterval: NodeJS.Timeout | null = null;
|
private healthCheckInterval: NodeJS.Timeout | null = null;
|
||||||
private readonly HEARTBEAT_TIMEOUT = 30000; // 30 seconds
|
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() {
|
constructor(password: string | null = null) {
|
||||||
this.startHeartbeatChecker();
|
this.password = password;
|
||||||
|
this.startHealthChecker();
|
||||||
}
|
}
|
||||||
|
|
||||||
register(remote: Omit<RemoteServer, 'registeredAt' | 'lastHeartbeat' | 'status'>): RemoteServer {
|
register(remote: Omit<RemoteServer, 'registeredAt' | 'lastHeartbeat' | 'status'>): RemoteServer {
|
||||||
|
|
@ -30,6 +33,9 @@ export class RemoteRegistry {
|
||||||
this.remotes.set(remote.id, registeredRemote);
|
this.remotes.set(remote.id, registeredRemote);
|
||||||
console.log(`Remote registered: ${remote.name} (${remote.id}) from ${remote.url}`);
|
console.log(`Remote registered: ${remote.name} (${remote.id}) from ${remote.url}`);
|
||||||
|
|
||||||
|
// Immediately check health of new remote
|
||||||
|
this.checkRemoteHealth(registeredRemote);
|
||||||
|
|
||||||
return registeredRemote;
|
return registeredRemote;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,17 +48,6 @@ export class RemoteRegistry {
|
||||||
return false;
|
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 {
|
getRemote(remoteId: string): RemoteServer | undefined {
|
||||||
return this.remotes.get(remoteId);
|
return this.remotes.get(remoteId);
|
||||||
}
|
}
|
||||||
|
|
@ -69,24 +64,60 @@ export class RemoteRegistry {
|
||||||
return this.getAllRemotes().filter((r) => r.status === 'online');
|
return this.getAllRemotes().filter((r) => r.status === 'online');
|
||||||
}
|
}
|
||||||
|
|
||||||
private startHeartbeatChecker() {
|
private async checkRemoteHealth(remote: RemoteServer): Promise<void> {
|
||||||
this.heartbeatInterval = setInterval(() => {
|
try {
|
||||||
const now = Date.now();
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), this.HEALTH_CHECK_TIMEOUT);
|
||||||
|
|
||||||
for (const remote of this.remotes.values()) {
|
const headers: Record<string, string> = {};
|
||||||
const timeSinceLastHeartbeat = now - remote.lastHeartbeat.getTime();
|
if (this.password) {
|
||||||
|
headers['Authorization'] =
|
||||||
if (timeSinceLastHeartbeat > this.HEARTBEAT_TIMEOUT && remote.status === 'online') {
|
`Basic ${Buffer.from(`user:${this.password}`).toString('base64')}`;
|
||||||
remote.status = 'offline';
|
|
||||||
console.log(`Remote went offline: ${remote.name} (${remote.id})`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}, 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() {
|
destroy() {
|
||||||
if (this.heartbeatInterval) {
|
if (this.healthCheckInterval) {
|
||||||
clearInterval(this.heartbeatInterval);
|
clearInterval(this.healthCheckInterval);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ let remoteRegistry: RemoteRegistry | null = null;
|
||||||
let hqClient: HQClient | null = null;
|
let hqClient: HQClient | null = null;
|
||||||
|
|
||||||
if (isHQMode) {
|
if (isHQMode) {
|
||||||
remoteRegistry = new RemoteRegistry();
|
remoteRegistry = new RemoteRegistry(basicAuthPassword);
|
||||||
console.log(`${GREEN}Running in HQ mode${RESET}`);
|
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 } });
|
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
|
// Unregister remote
|
||||||
app.delete('/api/remotes/:remoteId', (req, res) => {
|
app.delete('/api/remotes/:remoteId', (req, res) => {
|
||||||
if (!isHQMode || !remoteRegistry) {
|
if (!isHQMode || !remoteRegistry) {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue