Files
openchamber/packages/ui/src/lib/terminalApi.ts
T

272 lines
7.0 KiB
TypeScript
Raw Normal View History

2025-12-07 19:32:53 +02:00
export interface TerminalSession {
sessionId: string;
cols: number;
rows: number;
}
export interface TerminalStreamEvent {
type: 'connected' | 'data' | 'exit' | 'reconnecting';
data?: string;
exitCode?: number;
signal?: number | null;
attempt?: number;
maxAttempts?: number;
}
export interface CreateTerminalOptions {
cwd: string;
cols?: number;
rows?: number;
}
export interface ConnectStreamOptions {
maxRetries?: number;
initialRetryDelay?: number;
maxRetryDelay?: number;
connectionTimeout?: number;
}
export async function createTerminalSession(
options: CreateTerminalOptions
): Promise<TerminalSession> {
const response = await fetch('/api/terminal/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
cwd: options.cwd,
cols: options.cols || 80,
rows: options.rows || 24,
}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Failed to create terminal' }));
throw new Error(error.error || 'Failed to create terminal session');
}
return response.json();
}
export function connectTerminalStream(
sessionId: string,
onEvent: (event: TerminalStreamEvent) => void,
onError?: (error: Error, fatal?: boolean) => void,
options: ConnectStreamOptions = {}
): () => void {
const {
maxRetries = 3,
initialRetryDelay = 1000,
maxRetryDelay = 8000,
connectionTimeout = 10000,
} = options;
let eventSource: EventSource | null = null;
let retryCount = 0;
let retryTimeout: ReturnType<typeof setTimeout> | null = null;
let connectionTimeoutId: ReturnType<typeof setTimeout> | null = null;
let isClosed = false;
let hasDispatchedOpen = false;
let terminalExited = false;
const clearTimeouts = () => {
if (retryTimeout) {
clearTimeout(retryTimeout);
retryTimeout = null;
}
if (connectionTimeoutId) {
clearTimeout(connectionTimeoutId);
connectionTimeoutId = null;
}
};
const cleanup = () => {
isClosed = true;
clearTimeouts();
if (eventSource) {
eventSource.close();
eventSource = null;
}
};
const connect = () => {
if (isClosed || terminalExited) {
return;
}
if (eventSource && eventSource.readyState !== EventSource.CLOSED) {
console.warn('Attempted to create duplicate EventSource, skipping');
return;
}
hasDispatchedOpen = false;
eventSource = new EventSource(`/api/terminal/${sessionId}/stream`);
connectionTimeoutId = setTimeout(() => {
if (!hasDispatchedOpen && eventSource?.readyState !== EventSource.OPEN) {
console.error('Terminal connection timeout');
eventSource?.close();
handleError(new Error('Connection timeout'), false);
}
}, connectionTimeout);
eventSource.onopen = () => {
if (hasDispatchedOpen) {
return;
}
hasDispatchedOpen = true;
retryCount = 0;
clearTimeouts();
onEvent({ type: 'connected' });
};
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data) as TerminalStreamEvent;
if (data.type === 'exit') {
terminalExited = true;
cleanup();
}
onEvent(data);
} catch (error) {
console.error('Failed to parse terminal event:', error);
onError?.(error as Error, false);
}
};
eventSource.onerror = (error) => {
console.error('Terminal stream error:', error, 'readyState:', eventSource?.readyState);
clearTimeouts();
const isFatalError = terminalExited || eventSource?.readyState === EventSource.CLOSED;
eventSource?.close();
eventSource = null;
if (!terminalExited) {
handleError(new Error('Terminal stream connection error'), isFatalError);
}
};
};
const handleError = (error: Error, isFatal: boolean) => {
if (isClosed || terminalExited) {
return;
}
if (retryCount < maxRetries && !isFatal) {
retryCount++;
const delay = Math.min(initialRetryDelay * Math.pow(2, retryCount - 1), maxRetryDelay);
console.log(`Reconnecting to terminal stream (attempt ${retryCount}/${maxRetries}) in ${delay}ms`);
onEvent({
type: 'reconnecting',
attempt: retryCount,
maxAttempts: maxRetries,
});
retryTimeout = setTimeout(() => {
if (!isClosed && !terminalExited) {
connect();
}
}, delay);
} else {
console.error(`Terminal connection failed after ${retryCount} attempts`);
onError?.(error, true);
cleanup();
}
};
connect();
return cleanup;
}
export async function sendTerminalInput(
sessionId: string,
data: string
): Promise<void> {
const response = await fetch(`/api/terminal/${sessionId}/input`, {
method: 'POST',
headers: { 'Content-Type': 'text/plain' },
body: data,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Failed to send input' }));
throw new Error(error.error || 'Failed to send terminal input');
}
}
export async function resizeTerminal(
sessionId: string,
cols: number,
rows: number
): Promise<void> {
const response = await fetch(`/api/terminal/${sessionId}/resize`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ cols, rows }),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Failed to resize terminal' }));
throw new Error(error.error || 'Failed to resize terminal');
}
}
export async function closeTerminal(sessionId: string): Promise<void> {
const response = await fetch(`/api/terminal/${sessionId}`, {
method: 'DELETE',
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Failed to close terminal' }));
throw new Error(error.error || 'Failed to close terminal');
}
}
export async function restartTerminalSession(
currentSessionId: string,
options: { cwd: string; cols?: number; rows?: number }
): Promise<TerminalSession> {
const response = await fetch(`/api/terminal/${currentSessionId}/restart`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
cwd: options.cwd,
cols: options.cols ?? 80,
rows: options.rows ?? 24,
}),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Failed to restart terminal' }));
throw new Error(error.error || 'Failed to restart terminal');
}
return response.json();
}
export async function forceKillTerminal(options: {
sessionId?: string;
cwd?: string;
}): Promise<void> {
const response = await fetch('/api/terminal/force-kill', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(options),
});
if (!response.ok) {
const error = await response.json().catch(() => ({ error: 'Failed to force kill terminal' }));
throw new Error(error.error || 'Failed to force kill terminal');
}
}