VOLT
Open Source Ecosystem

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-client

Peer 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:

  1. Enrollment (only when enrollment.url is configured and a credentials.enrollmentToken is present, unless enrollment.enabled === false): HTTP POST to the healthcheck endpoint. The server may rotate the daemon password.
  2. Socket connection: establishes a socket.io connection to the control socket and completes registration.
  3. Heartbeat: starts a periodic loop that sends runtime.heartbeat commands.
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:

CodeMeaning
SOCKET_NOT_READYSocket is not connected or registration incomplete
COMMAND_TIMEOUTServer did not respond within the timeout
COMMAND_REJECTEDServer 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 (default 200)
  • data — JSON-serialisable response data
  • body — a raw Buffer
  • headers
  • stream — a ReadableStream<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 valueEnum memberDescription
ENROLLMENT_FAILEDDaemonClientErrorCode.EnrollmentFailedHTTP enrollment request failed
SOCKET_CONNECTION_FAILEDDaemonClientErrorCode.SocketConnectionFailedCould not establish socket.io connection
SOCKET_REGISTRATION_FAILEDDaemonClientErrorCode.SocketRegistrationFailedSocket connected but registration was rejected
COMMAND_TIMEOUTDaemonClientErrorCode.CommandTimeoutServer did not respond within the timeout
COMMAND_REJECTEDDaemonClientErrorCode.CommandRejectedServer explicitly rejected the command
SOCKET_NOT_READYDaemonClientErrorCode.SocketNotReadyAttempted to send before connection was ready
HANDLER_ERRORDaemonClientErrorCode.HandlerErrorAn inbound command handler threw an error
EMIT_FAILEDDaemonClientErrorCode.EmitFailedFire-and-forget emit failed

Architecture

The client orchestrates four internal components:

ComponentResponsibility
EnrollmentClientHTTP-based daemon registration and password rotation
ControlSocketManagersocket.io connection, command send/receive, registration
HeartbeatManagerPeriodic runtime.heartbeat commands to keep the connection alive
ReverseChannelBridgeInbound command dispatch and non-command message routing

On this page