mirror of
https://github.com/samsonjs/vibetunnel.git
synced 2026-03-25 09:25:50 +00:00
Refactor socket client API: Remove HTTP fallback and add type-safe message handling
- Remove HTTP fallback and PID file management from socket-api-client - Delete ServerPidManager utility class - Add type-safe sendMessage and sendMessageWithResponse methods to VibeTunnelSocketClient - Add MessagePayloadMap for compile-time type safety of message payloads - Refactor SocketApiClient to use clean API instead of brittle type casting - Remove backwards compatibility code - only emit events with MessageType enum names - Simplify message handling and response listeners - Update buildMessage to properly handle CONTROL_CMD messages
This commit is contained in:
parent
9d7fe36699
commit
2b20fa9555
4 changed files with 167 additions and 130 deletions
15
web/bin/vt
15
web/bin/vt
|
|
@ -141,6 +141,13 @@ if [ -n "$VIBETUNNEL_PREFER_DERIVED_DATA" ] && [ -n "$VIBETUNNEL_BIN" ]; then
|
|||
fi
|
||||
fi
|
||||
|
||||
# Handle safe commands first that work both inside and outside sessions
|
||||
# This must come BEFORE the session check to avoid the recursive session error
|
||||
if [[ "$1" == "status" || "$1" == "version" || "$1" == "--version" ]]; then
|
||||
# These commands can run safely inside or outside a session
|
||||
exec "$VIBETUNNEL_BIN" "$@"
|
||||
fi
|
||||
|
||||
# Check if we're already inside a VibeTunnel session
|
||||
if [ -n "$VIBETUNNEL_SESSION_ID" ]; then
|
||||
# Special case: handle 'vt title' command inside a session
|
||||
|
|
@ -159,6 +166,7 @@ if [ -n "$VIBETUNNEL_SESSION_ID" ]; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
# For all other commands, block recursive sessions
|
||||
echo "Error: Already inside a VibeTunnel session (ID: $VIBETUNNEL_SESSION_ID). Recursive VibeTunnel sessions are not supported." >&2
|
||||
echo "If you need to run commands, use them directly without the 'vt' prefix." >&2
|
||||
exit 1
|
||||
|
|
@ -365,6 +373,7 @@ if [[ $# -eq 0 || "$1" == "--help" || "$1" == "-h" ]]; then
|
|||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
# Handle 'vt title' command when not inside a session
|
||||
if [[ "$1" == "title" ]]; then
|
||||
echo "Error: 'vt title' can only be used inside a VibeTunnel session." >&2
|
||||
|
|
@ -372,12 +381,6 @@ if [[ "$1" == "title" ]]; then
|
|||
exit 1
|
||||
fi
|
||||
|
||||
# Handle 'vt status' command
|
||||
if [[ "$1" == "status" ]]; then
|
||||
# Use vibetunnel CLI to show status via socket
|
||||
exec "$VIBETUNNEL_BIN" status
|
||||
fi
|
||||
|
||||
# Handle 'vt follow' command
|
||||
if [[ "$1" == "follow" ]]; then
|
||||
# Detect if we're in a worktree
|
||||
|
|
|
|||
|
|
@ -6,12 +6,20 @@ import { EventEmitter } from 'events';
|
|||
import * as net from 'net';
|
||||
import { createLogger } from '../utils/logger.js';
|
||||
import {
|
||||
type ControlCommand,
|
||||
type ErrorMessage,
|
||||
frameMessage,
|
||||
type GitEventNotify,
|
||||
type GitFollowRequest,
|
||||
type KillCommand,
|
||||
MessageBuilder,
|
||||
MessageParser,
|
||||
type MessagePayload,
|
||||
MessageType,
|
||||
parsePayload,
|
||||
type ResizeCommand,
|
||||
type StatusUpdate,
|
||||
type UpdateTitleCommand,
|
||||
} from './socket-protocol.js';
|
||||
|
||||
const logger = createLogger('socket-client');
|
||||
|
|
@ -20,8 +28,8 @@ export interface SocketClientEvents {
|
|||
connect: () => void;
|
||||
disconnect: (error?: Error) => void;
|
||||
error: (error: Error) => void;
|
||||
status: (status: StatusUpdate) => void;
|
||||
serverError: (error: ErrorMessage) => void;
|
||||
// Message-specific events are emitted using MessageType enum names
|
||||
// e.g., 'STATUS_UPDATE', 'ERROR', 'HEARTBEAT', etc.
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -156,23 +164,14 @@ export class VibeTunnelSocketClient extends EventEmitter {
|
|||
try {
|
||||
const data = parsePayload(type, payload);
|
||||
|
||||
switch (type) {
|
||||
case MessageType.STATUS_UPDATE:
|
||||
this.emit('status', data as StatusUpdate);
|
||||
break;
|
||||
// Emit event with message type enum name
|
||||
this.emit(MessageType[type], data);
|
||||
|
||||
case MessageType.ERROR:
|
||||
this.emit('serverError', data as ErrorMessage);
|
||||
break;
|
||||
|
||||
case MessageType.HEARTBEAT:
|
||||
this.lastHeartbeat = Date.now();
|
||||
// Echo heartbeat back
|
||||
this.sendHeartbeat();
|
||||
break;
|
||||
|
||||
default:
|
||||
logger.debug(`Received unexpected message type: ${type}`);
|
||||
// Handle heartbeat
|
||||
if (type === MessageType.HEARTBEAT) {
|
||||
this.lastHeartbeat = Date.now();
|
||||
// Echo heartbeat back
|
||||
this.sendHeartbeat();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to parse message:', error);
|
||||
|
|
@ -261,6 +260,104 @@ export class VibeTunnelSocketClient extends EventEmitter {
|
|||
return this.send(MessageBuilder.status(app, status, extra));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message with type-safe payload
|
||||
*/
|
||||
public sendMessage<T extends MessageType>(type: T, payload: MessagePayload<T>): boolean {
|
||||
const message = this.buildMessage(type, payload);
|
||||
return this.send(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a message and wait for a response
|
||||
*/
|
||||
public async sendMessageWithResponse<TRequest extends MessageType, TResponse extends MessageType>(
|
||||
requestType: TRequest,
|
||||
payload: MessagePayload<TRequest>,
|
||||
responseType: TResponse,
|
||||
timeout = 5000
|
||||
): Promise<MessagePayload<TResponse>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.off(MessageType[responseType], handleResponse);
|
||||
this.off('error', handleError);
|
||||
reject(new Error(`Request timeout waiting for ${MessageType[responseType]}`));
|
||||
}, timeout);
|
||||
|
||||
const handleResponse = (data: MessagePayload<TResponse>) => {
|
||||
clearTimeout(timer);
|
||||
this.off('error', handleError);
|
||||
resolve(data);
|
||||
};
|
||||
|
||||
const handleError = (error: Error | ErrorMessage) => {
|
||||
clearTimeout(timer);
|
||||
this.off(MessageType[responseType], handleResponse);
|
||||
if ('message' in error) {
|
||||
reject(new Error(error.message));
|
||||
} else {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for response
|
||||
this.once(MessageType[responseType], handleResponse);
|
||||
this.once('error', handleError);
|
||||
|
||||
const sent = this.sendMessage(requestType, payload);
|
||||
if (!sent) {
|
||||
clearTimeout(timer);
|
||||
this.off(MessageType[responseType], handleResponse);
|
||||
this.off('error', handleError);
|
||||
reject(new Error('Failed to send message'));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a message buffer from type and payload
|
||||
*/
|
||||
private buildMessage<T extends MessageType>(type: T, payload: MessagePayload<T>): Buffer {
|
||||
switch (type) {
|
||||
case MessageType.STDIN_DATA:
|
||||
return MessageBuilder.stdin(payload as string);
|
||||
case MessageType.CONTROL_CMD: {
|
||||
const cmd = payload as ControlCommand;
|
||||
switch (cmd.cmd) {
|
||||
case 'resize':
|
||||
return MessageBuilder.resize((cmd as ResizeCommand).cols, (cmd as ResizeCommand).rows);
|
||||
case 'kill':
|
||||
return MessageBuilder.kill((cmd as KillCommand).signal);
|
||||
case 'reset-size':
|
||||
return MessageBuilder.resetSize();
|
||||
case 'update-title':
|
||||
return MessageBuilder.updateTitle((cmd as UpdateTitleCommand).title);
|
||||
default:
|
||||
// For generic control commands, use frameMessage directly
|
||||
return frameMessage(MessageType.CONTROL_CMD, cmd);
|
||||
}
|
||||
}
|
||||
case MessageType.STATUS_UPDATE: {
|
||||
const statusPayload = payload as StatusUpdate;
|
||||
return MessageBuilder.status(
|
||||
statusPayload.app,
|
||||
statusPayload.status,
|
||||
statusPayload.extra as Record<string, unknown> | undefined
|
||||
);
|
||||
}
|
||||
case MessageType.HEARTBEAT:
|
||||
return MessageBuilder.heartbeat();
|
||||
case MessageType.STATUS_REQUEST:
|
||||
return MessageBuilder.statusRequest();
|
||||
case MessageType.GIT_FOLLOW_REQUEST:
|
||||
return MessageBuilder.gitFollowRequest(payload as GitFollowRequest);
|
||||
case MessageType.GIT_EVENT_NOTIFY:
|
||||
return MessageBuilder.gitEventNotify(payload as GitEventNotify);
|
||||
default:
|
||||
throw new Error(`Unsupported message type: ${type}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send heartbeat
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -136,6 +136,30 @@ export interface GitEventAck {
|
|||
handled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Type-safe mapping of message types to their payload types
|
||||
*/
|
||||
export type MessagePayloadMap = {
|
||||
[MessageType.STDIN_DATA]: string;
|
||||
[MessageType.CONTROL_CMD]: ControlCommand;
|
||||
[MessageType.STATUS_UPDATE]: StatusUpdate;
|
||||
[MessageType.HEARTBEAT]: Record<string, never>;
|
||||
[MessageType.ERROR]: ErrorMessage;
|
||||
[MessageType.STATUS_REQUEST]: StatusRequest;
|
||||
[MessageType.STATUS_RESPONSE]: StatusResponse;
|
||||
[MessageType.GIT_FOLLOW_REQUEST]: GitFollowRequest;
|
||||
[MessageType.GIT_FOLLOW_RESPONSE]: GitFollowResponse;
|
||||
[MessageType.GIT_EVENT_NOTIFY]: GitEventNotify;
|
||||
[MessageType.GIT_EVENT_ACK]: GitEventAck;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the payload type for a given message type
|
||||
*/
|
||||
export type MessagePayload<T extends MessageType> = T extends keyof MessagePayloadMap
|
||||
? MessagePayloadMap[T]
|
||||
: never;
|
||||
|
||||
/**
|
||||
* Frame a message for transmission
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import {
|
|||
type GitEventNotify,
|
||||
type GitFollowRequest,
|
||||
type GitFollowResponse,
|
||||
type MessagePayload,
|
||||
MessageType,
|
||||
} from './pty/socket-protocol.js';
|
||||
import { createLogger } from './utils/logger.js';
|
||||
|
|
@ -52,9 +53,9 @@ export class SocketApiClient {
|
|||
/**
|
||||
* Send a request and wait for response
|
||||
*/
|
||||
private async sendRequest<TRequest, TResponse>(
|
||||
type: MessageType,
|
||||
payload: TRequest,
|
||||
private async sendRequest<TRequest extends MessageType, TResponse>(
|
||||
type: TRequest,
|
||||
payload: MessagePayload<TRequest>,
|
||||
responseType: MessageType,
|
||||
timeout = 5000
|
||||
): Promise<TResponse> {
|
||||
|
|
@ -64,106 +65,18 @@ export class SocketApiClient {
|
|||
|
||||
const client = new VibeTunnelSocketClient(this.controlSocketPath);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
client.disconnect();
|
||||
reject(new Error('Request timeout'));
|
||||
}, timeout);
|
||||
|
||||
let responseReceived = false;
|
||||
|
||||
client.on('error', (error) => {
|
||||
clearTimeout(timer);
|
||||
if (!responseReceived) {
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
|
||||
// Handle the specific response type we're expecting
|
||||
const handleMessage = (msgType: MessageType, data: unknown) => {
|
||||
if (msgType === responseType) {
|
||||
responseReceived = true;
|
||||
clearTimeout(timer);
|
||||
client.disconnect();
|
||||
resolve(data as TResponse);
|
||||
} else if (msgType === MessageType.ERROR) {
|
||||
responseReceived = true;
|
||||
clearTimeout(timer);
|
||||
client.disconnect();
|
||||
reject(new Error((data as { message?: string }).message || 'Server error'));
|
||||
}
|
||||
};
|
||||
|
||||
// Override the handleMessage method to intercept messages
|
||||
(client as unknown as { handleMessage: typeof handleMessage }).handleMessage = handleMessage;
|
||||
|
||||
client
|
||||
.connect()
|
||||
.then(() => {
|
||||
// Send the request
|
||||
let message: unknown;
|
||||
switch (type) {
|
||||
case MessageType.STATUS_REQUEST:
|
||||
message = (client as unknown as { send: (msg: unknown) => unknown }).send(
|
||||
(
|
||||
client as unknown as {
|
||||
constructor: {
|
||||
prototype: {
|
||||
constructor: {
|
||||
MessageBuilder: Record<string, (...args: unknown[]) => unknown>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
).constructor.prototype.constructor.MessageBuilder.statusRequest()
|
||||
);
|
||||
break;
|
||||
case MessageType.GIT_FOLLOW_REQUEST:
|
||||
message = (client as unknown as { send: (msg: unknown) => unknown }).send(
|
||||
(
|
||||
client as unknown as {
|
||||
constructor: {
|
||||
prototype: {
|
||||
constructor: {
|
||||
MessageBuilder: Record<string, (...args: unknown[]) => unknown>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
).constructor.prototype.constructor.MessageBuilder.gitFollowRequest(payload)
|
||||
);
|
||||
break;
|
||||
case MessageType.GIT_EVENT_NOTIFY:
|
||||
message = (client as unknown as { send: (msg: unknown) => unknown }).send(
|
||||
(
|
||||
client as unknown as {
|
||||
constructor: {
|
||||
prototype: {
|
||||
constructor: {
|
||||
MessageBuilder: Record<string, (...args: unknown[]) => unknown>;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
).constructor.prototype.constructor.MessageBuilder.gitEventNotify(payload)
|
||||
);
|
||||
break;
|
||||
default:
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`Unsupported message type: ${type}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!message) {
|
||||
clearTimeout(timer);
|
||||
reject(new Error('Failed to send request'));
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
try {
|
||||
await client.connect();
|
||||
const response = await client.sendMessageWithResponse(type, payload, responseType, timeout);
|
||||
return response as TResponse;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('ENOENT')) {
|
||||
throw new Error('VibeTunnel server is not running');
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
client.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -176,7 +89,7 @@ export class SocketApiClient {
|
|||
|
||||
try {
|
||||
// Send STATUS_REQUEST and wait for STATUS_RESPONSE
|
||||
const response = await this.sendRequest<Record<string, never>, ServerStatus>(
|
||||
const response = await this.sendRequest<MessageType.STATUS_REQUEST, ServerStatus>(
|
||||
MessageType.STATUS_REQUEST,
|
||||
{},
|
||||
MessageType.STATUS_RESPONSE
|
||||
|
|
@ -192,7 +105,7 @@ export class SocketApiClient {
|
|||
* Enable or disable Git follow mode
|
||||
*/
|
||||
async setFollowMode(request: GitFollowRequest): Promise<GitFollowResponse> {
|
||||
return this.sendRequest<GitFollowRequest, GitFollowResponse>(
|
||||
return this.sendRequest<MessageType.GIT_FOLLOW_REQUEST, GitFollowResponse>(
|
||||
MessageType.GIT_FOLLOW_REQUEST,
|
||||
request,
|
||||
MessageType.GIT_FOLLOW_RESPONSE
|
||||
|
|
@ -203,7 +116,7 @@ export class SocketApiClient {
|
|||
* Send Git event notification
|
||||
*/
|
||||
async sendGitEvent(event: GitEventNotify): Promise<GitEventAck> {
|
||||
return this.sendRequest<GitEventNotify, GitEventAck>(
|
||||
return this.sendRequest<MessageType.GIT_EVENT_NOTIFY, GitEventAck>(
|
||||
MessageType.GIT_EVENT_NOTIFY,
|
||||
event,
|
||||
MessageType.GIT_EVENT_ACK
|
||||
|
|
|
|||
Loading…
Reference in a new issue