sidecar self-registration: sidecars connect to API server instead of vice versa
Flips the connection model so sidecars register themselves with the API server via WebSocket at /api/sidecar/register, enabling dynamic discovery, location independence, and automatic reconnection from either side. - Add registration protocol types and PTY command/event types - Create sidecar-registry.ts (replaces sidecar-client.ts) as passive registry - Create sidecar connector (connect.ts) with exponential backoff reconnect - Convert process sidecar from WS server to WS client - Convert PTY sidecar from WS server to multiplexed WS client - Simplify terminal bridge to thin adapter using registry - Add PTY sidecar as PM2-managed process - Update all consumer imports Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
import type { SidecarCommand, SidecarEvent, PtyCommand, PtyEvent } from './protocol';
|
||||
import type { SidecarRegistration, RegistrationAck } from './registration-protocol';
|
||||
|
||||
type AnyCommand = SidecarCommand | PtyCommand;
|
||||
type AnyEvent = SidecarEvent | PtyEvent;
|
||||
|
||||
type SidecarConnectorConfig = {
|
||||
apiUrl: string; // ws://127.0.0.1:5000/api/sidecar/register
|
||||
name: string;
|
||||
capabilities: string[];
|
||||
onCommand: (cmd: AnyCommand, reply: (msg: AnyEvent) => void) => void;
|
||||
onConnected?: () => void;
|
||||
onDisconnected?: () => void;
|
||||
};
|
||||
|
||||
type SidecarConnection = {
|
||||
send: (msg: AnyEvent) => void;
|
||||
destroy: () => void;
|
||||
isConnected: () => boolean;
|
||||
};
|
||||
|
||||
const RECONNECT_DELAYS = [200, 500, 1000, 2000, 4000, 8000, 15000];
|
||||
|
||||
export function createSidecarConnector(config: SidecarConnectorConfig): SidecarConnection {
|
||||
let ws: WebSocket | null = null;
|
||||
let connected = false;
|
||||
let reconnectAttempt = 0;
|
||||
let reconnectTimer: Timer | null = null;
|
||||
let destroyed = false;
|
||||
let sidecarId: string | null = null;
|
||||
|
||||
function connect() {
|
||||
if (destroyed) return;
|
||||
if (ws && (ws.readyState === WebSocket.CONNECTING || ws.readyState === WebSocket.OPEN)) return;
|
||||
|
||||
try {
|
||||
ws = new WebSocket(config.apiUrl);
|
||||
} catch {
|
||||
scheduleReconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectAttempt = 0;
|
||||
|
||||
// Send registration
|
||||
const registration: SidecarRegistration = {
|
||||
type: 'register',
|
||||
name: config.name,
|
||||
capabilities: config.capabilities,
|
||||
};
|
||||
ws!.send(JSON.stringify(registration));
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data as string);
|
||||
|
||||
// Handle registration ack
|
||||
if (msg.type === 'registered') {
|
||||
const ack = msg as RegistrationAck;
|
||||
sidecarId = ack.id;
|
||||
connected = true;
|
||||
console.log(`[sidecar] registered with API server (id=${sidecarId})`);
|
||||
config.onConnected?.();
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle commands from API server
|
||||
const reply = (response: AnyEvent) => {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(response));
|
||||
}
|
||||
};
|
||||
config.onCommand(msg as AnyCommand, reply);
|
||||
} catch {
|
||||
// skip malformed messages
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
const wasConnected = connected;
|
||||
connected = false;
|
||||
sidecarId = null;
|
||||
ws = null;
|
||||
if (wasConnected) {
|
||||
console.log('[sidecar] disconnected from API server');
|
||||
config.onDisconnected?.();
|
||||
}
|
||||
scheduleReconnect();
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
// onclose will fire after this
|
||||
};
|
||||
}
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (destroyed || reconnectTimer) return;
|
||||
const delay = RECONNECT_DELAYS[Math.min(reconnectAttempt, RECONNECT_DELAYS.length - 1)]!;
|
||||
reconnectAttempt++;
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
function send(msg: AnyEvent): void {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify(msg));
|
||||
}
|
||||
}
|
||||
|
||||
function destroy(): void {
|
||||
destroyed = true;
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
if (ws) {
|
||||
ws.close();
|
||||
ws = null;
|
||||
}
|
||||
connected = false;
|
||||
}
|
||||
|
||||
// Start connecting
|
||||
connect();
|
||||
|
||||
return {
|
||||
send,
|
||||
destroy,
|
||||
isConnected: () => connected,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user