DaemonClusterClient
TypeScript WebSocket client for cluster daemon communication with the VOLT server.
Overview
@voltstack/daemon-cluster-client lets cluster daemons communicate with the VOLT server over WebSocket (socket.io): enrollment, heartbeat, bidirectional command dispatch, and real-time messaging.
Used internally by the VOLT cluster daemon; use it directly to build a custom daemon or integrate cluster-level services.
Installation
npm install @voltstack/daemon-cluster-clientPeer dependency: socket.io-client >= 4.0.0
Quick Start
import { ClusterDaemonClient } from '@voltstack/daemon-cluster-client';
const client = new ClusterDaemonClient({
serverUrl: 'https://api.voltcloud.dev',
controlSocketUrl: 'wss://api.voltcloud.dev',
credentials: {
teamClusterId: 'cluster-abc123',
daemonPassword: 'password',
},
});
// Handlers are ReverseChannelHandler objects with a handle() method.
client.registerHandler('execute-plugin', {
async handle(payload, context) {
// Handle inbound command from the server.
return { data: { success: true } };
},
});
await client.connect();Constructor
class ClusterDaemonClient {
constructor(options: ClusterDaemonClientOptions)
}Options
interface ClusterDaemonClientOptions {
// Base URL of the VOLT server (used with enrollment.url for the healthcheck request)
serverUrl: string;
// socket.io control socket URL, e.g. wss://api.voltcloud.dev (no namespace path)
controlSocketUrl: string;
// Authentication credentials
credentials: DaemonCredentials;
// Optional: enrollment (healthcheck) configuration
enrollment?: EnrollmentOptions;
// Optional: heartbeat configuration
heartbeat?: HeartbeatOptions;
// Optional: control socket options
socket?: SocketOptions;
// Optional: default command timeout in ms (default: 30000)
commandTimeout?: number;
}
interface DaemonCredentials {
teamClusterId: string;
daemonPassword: string;
// One-time enrollment token issued when the cluster was provisioned
enrollmentToken?: string;
// Semantic version string reported to the server during enrollment
installedVersion?: string;
}
interface EnrollmentOptions {
// Full URL of the healthcheck endpoint, required when enrollment is enabled,
// e.g. https://api.voltcloud.dev/api/team-clusters/<id>/healthcheck
url: string;
// Whether to perform enrollment on connect() (default: true when enrollmentToken is present)
enabled?: boolean;
}
interface HeartbeatOptions {
// Interval in ms (default: 30000)
interval?: number;
// Random jitter in ms added to each interval (default: 0). Delay = interval + random(0, jitter)
jitter?: number;
// Factory called before each heartbeat to build the payload (default: empty object)
payloadFactory?: () => object | Promise<object>;
}
interface SocketOptions {
// Automatically reconnect on disconnect (default: true)
reconnect?: boolean;
// Maximum reconnection attempts; Infinity retries forever (default: Infinity)
maxReconnectAttempts?: number;
// Base delay in ms for the first reconnection attempt (default: 500)
reconnectBaseDelayMs?: number;
// Maximum reconnection delay cap in ms (default: 30000)
reconnectMaxDelayMs?: number;
// Randomization factor (0-1) added to the reconnection delay (default: 0.3)
randomizationFactor?: number;
}Connection Lifecycle
┌─────────────┐ ┌──────────────┐ ┌───────────────┐
│ Enrollment │ ──► │ Socket.io │ ──► │ Heartbeat │
│ (HTTP POST) │ │ Connect │ │ Loop Start │
└─────────────┘ └──────────────┘ └───────────────┘connect(): Promise<void>
Runs the full connection sequence:
- Enrollment (only when
enrollment.urlis configured and acredentials.enrollmentTokenis present, unlessenrollment.enabled === false): HTTP POST to the healthcheck endpoint. The server may rotate the daemon password. - Socket connection: establishes a socket.io connection to the control socket and completes registration.
- Heartbeat: starts a periodic loop that sends
runtime.heartbeatcommands.
const client = new ClusterDaemonClient({
serverUrl: 'https://api.voltcloud.dev',
controlSocketUrl: 'wss://api.voltcloud.dev',
credentials: {
teamClusterId: 'cluster-abc123',
daemonPassword: 'password',
enrollmentToken: 'one-time-token',
},
enrollment: {
url: 'https://api.voltcloud.dev/api/team-clusters/cluster-abc123/healthcheck',
},
});
await client.connect();disconnect(): void
Gracefully disconnects the socket and stops the heartbeat loop.
client.disconnect();isReady(): boolean
Returns true if the socket is connected and registration is complete.
if (client.isReady()) {
await client.sendCommand('status');
}Sending Commands
sendCommand<T>(command, payload?, timeout?): Promise<T | undefined>
Sends a request to the VOLT server and waits for a response, using a UUID-keyed request/response pattern with timeout.
// Send a command and wait for response
const result = await client.sendCommand('get-container-stats', {
containerId: 'container-123',
});
// With custom timeout (ms)
const result = await client.sendCommand('long-operation', payload, 60000);Throws DaemonClientError with codes:
| Code | Meaning |
|---|---|
SOCKET_NOT_READY | Socket is not connected or registration incomplete |
COMMAND_TIMEOUT | Server did not respond within the timeout |
COMMAND_REJECTED | Server explicitly rejected the command |
emit(message): void
Sends a fire-and-forget message on the control socket, for notifications that do not require a response.
client.emit({
type: 'exposure-snapshot',
exposures: [/* full exposure registry */],
});Handling Inbound Commands
The VOLT server can send commands to the daemon (e.g., "run this plugin", "list container files"). Register handlers to respond.
registerHandler(command, handler): this
Registers a handler for a specific inbound command. Handlers survive reconnections.
A handler is a ReverseChannelHandler object with a handle(payload, context) method returning a CommandResult (all fields optional):
status— HTTP-style status code (default200)data— JSON-serialisable response databody— a rawBufferheadersstream— aReadableStream<Uint8Array>
body, data, and stream are mutually exclusive. The bridge wraps the result into a response envelope and sends it back to the server automatically.
client.registerHandler('execute-plugin', {
async handle(payload, context) {
const result = await runPlugin(payload.pluginId, payload.params);
return { data: result };
},
});
client.registerHandler('list-files', {
async handle(payload, context) {
return { data: fs.readdirSync(payload.path) };
},
});The context argument provides the originating command name and the requestId from the incoming command envelope.
unregisterHandler(command): this
Removes a previously registered handler.
client.unregisterHandler('execute-plugin');onMessage(callback): this
Subscribes to all inbound non-command messages (e.g., session input, tunnel events, exposure snapshots).
Each message is a member of the TeamClusterDaemonMessage union, discriminated by type (e.g. session-input, session-data, tunnel-open, tunnel-state, exposure-snapshot); narrow on message.type to access the type-specific fields.
client.onMessage((message) => {
if (message.type === 'session-input') {
handleSessionInput(message.sessionId, message.chunkBase64);
}
});Event Listeners
onConnected(callback): this
Fires when the socket connection is established and registration is complete.
client.onConnected(() => {
console.log('Daemon connected and registered');
});onDisconnected(callback): this
Fires when the socket disconnects.
client.onDisconnected((reason) => {
console.log('Disconnected:', reason);
});onError(callback): this
Fires on client errors (heartbeat failures, handler errors, socket errors).
client.onError((err) => {
console.error(err.code, err.message);
});Credential Accessors
getTeamClusterId(): string
Returns the current team cluster ID.
getDaemonPassword(): string
Returns the current daemon password. This may differ from the initial value if the password was rotated during enrollment.
Error Handling
import { DaemonClientError } from '@voltstack/daemon-cluster-client';
try {
await client.sendCommand('some-command');
} catch (err) {
if (err instanceof DaemonClientError) {
switch (err.code) {
case 'COMMAND_TIMEOUT':
// Handle timeout
break;
case 'SOCKET_NOT_READY':
// Handle not connected
break;
case 'COMMAND_REJECTED':
// Handle rejection
break;
}
}
}Error Codes
err.code is the string value below; the corresponding DaemonClientErrorCode enum member is shown for reference.
err.code value | Enum member | Description |
|---|---|---|
ENROLLMENT_FAILED | DaemonClientErrorCode.EnrollmentFailed | HTTP enrollment request failed |
SOCKET_CONNECTION_FAILED | DaemonClientErrorCode.SocketConnectionFailed | Could not establish socket.io connection |
SOCKET_REGISTRATION_FAILED | DaemonClientErrorCode.SocketRegistrationFailed | Socket connected but registration was rejected |
COMMAND_TIMEOUT | DaemonClientErrorCode.CommandTimeout | Server did not respond within the timeout |
COMMAND_REJECTED | DaemonClientErrorCode.CommandRejected | Server explicitly rejected the command |
SOCKET_NOT_READY | DaemonClientErrorCode.SocketNotReady | Attempted to send before connection was ready |
HANDLER_ERROR | DaemonClientErrorCode.HandlerError | An inbound command handler threw an error |
EMIT_FAILED | DaemonClientErrorCode.EmitFailed | Fire-and-forget emit failed |
Architecture
The client orchestrates four internal components:
| Component | Responsibility |
|---|---|
| EnrollmentClient | HTTP-based daemon registration and password rotation |
| ControlSocketManager | socket.io connection, command send/receive, registration |
| HeartbeatManager | Periodic runtime.heartbeat commands to keep the connection alive |
| ReverseChannelBridge | Inbound command dispatch and non-command message routing |