feat(terminal): switch terminal transport to pure websocket with fallback (#762)
* feat(terminal): add resumable websocket transport Unify terminal input and stream traffic on `/api/terminal/ws` with a v2 control-frame protocol and advertised transport capabilities. Buffer recent PTY output on the server so rebinding clients can replay missed chunks after reconnects or startup races, while keeping SSE as a fallback stream path. Update the web terminal client and store to negotiate the new transport, track tab lifecycle, and avoid reopening exited sessions when restoring tabs. * fix(terminal): retry rehydrated websocket reconnects * fix(terminal): keep reconnect retries silent * fix(terminal): wire ws stream output and replay in runtime * fix(web): enable ws proxy for /api in dev --------- Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
Bohdan Triapitsyn
parent
d68bec491c
commit
2b70ec6f3a
@@ -54,7 +54,7 @@ const STREAM_OPTIONS = {
|
||||
|
||||
const REHYDRATED_STREAM_OPTIONS = {
|
||||
retry: {
|
||||
maxRetries: 0,
|
||||
...STREAM_OPTIONS.retry,
|
||||
initialDelayMs: 200,
|
||||
maxDelayMs: 500,
|
||||
},
|
||||
@@ -106,6 +106,7 @@ export const TerminalView: React.FC = () => {
|
||||
const setActiveTab = terminalStore.setActiveTab;
|
||||
const closeTab = terminalStore.closeTab;
|
||||
const setTabSessionId = terminalStore.setTabSessionId;
|
||||
const setTabLifecycle = terminalStore.setTabLifecycle;
|
||||
const setConnecting = terminalStore.setConnecting;
|
||||
const appendToBuffer = terminalStore.appendToBuffer;
|
||||
|
||||
@@ -132,11 +133,13 @@ export const TerminalView: React.FC = () => {
|
||||
}, [directoryTerminalState, activeTabId]);
|
||||
|
||||
const terminalSessionId = activeTab?.terminalSessionId ?? null;
|
||||
const terminalLifecycle = activeTab?.lifecycle ?? 'idle';
|
||||
const bufferChunks = activeTab?.bufferChunks ?? [];
|
||||
const isConnecting = activeTab?.isConnecting ?? false;
|
||||
|
||||
const [connectionError, setConnectionError] = React.useState<string | null>(null);
|
||||
const [isFatalError, setIsFatalError] = React.useState(false);
|
||||
const [isReconnectPending, setIsReconnectPending] = React.useState(false);
|
||||
const [activeModifier, setActiveModifier] = React.useState<Modifier | null>(null);
|
||||
const [isRestarting, setIsRestarting] = React.useState(false);
|
||||
const [viewportLayoutVersion, setViewportLayoutVersion] = React.useState(0);
|
||||
@@ -234,6 +237,7 @@ export const TerminalView: React.FC = () => {
|
||||
streamCleanupRef.current?.();
|
||||
streamCleanupRef.current = null;
|
||||
activeTerminalIdRef.current = null;
|
||||
setIsReconnectPending(false);
|
||||
}, []);
|
||||
|
||||
React.useEffect(
|
||||
@@ -273,6 +277,7 @@ export const TerminalView: React.FC = () => {
|
||||
setConnecting(directory, tabId, false);
|
||||
setConnectionError(null);
|
||||
setIsFatalError(false);
|
||||
setIsReconnectPending(false);
|
||||
focusTerminalWhenWindowActive();
|
||||
|
||||
// After a reload, buffer is empty and a reused PTY can look "stuck"
|
||||
@@ -286,10 +291,10 @@ export const TerminalView: React.FC = () => {
|
||||
break;
|
||||
}
|
||||
case 'reconnecting': {
|
||||
const attempt = event.attempt ?? 0;
|
||||
const maxAttempts = event.maxAttempts ?? 3;
|
||||
setConnectionError(`Reconnecting (${attempt}/${maxAttempts})...`);
|
||||
void event;
|
||||
setConnectionError(null);
|
||||
setIsFatalError(false);
|
||||
setIsReconnectPending(true);
|
||||
break;
|
||||
}
|
||||
case 'data': {
|
||||
@@ -313,10 +318,12 @@ export const TerminalView: React.FC = () => {
|
||||
exitCode !== null ? ` with code ${exitCode}` : ''
|
||||
}${signal !== null ? ` (signal ${signal})` : ''}]\r\n`
|
||||
);
|
||||
setTabLifecycle(directory, tabId, 'exited');
|
||||
setTabSessionId(directory, tabId, null);
|
||||
setConnecting(directory, tabId, false);
|
||||
setConnectionError(isActionTab ? null : 'Terminal session ended');
|
||||
setIsFatalError(false);
|
||||
setIsReconnectPending(false);
|
||||
disconnectStream();
|
||||
break;
|
||||
}
|
||||
@@ -327,18 +334,19 @@ export const TerminalView: React.FC = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorMsg = fatal
|
||||
? `Connection failed: ${error.message}`
|
||||
: error.message || 'Terminal stream connection error';
|
||||
|
||||
setConnectionError(errorMsg);
|
||||
setIsFatalError(!!fatal);
|
||||
|
||||
if (fatal) {
|
||||
setConnecting(directory, tabId, false);
|
||||
setTabSessionId(directory, tabId, null);
|
||||
disconnectStream();
|
||||
if (!fatal) {
|
||||
setConnectionError(null);
|
||||
setIsFatalError(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsReconnectPending(false);
|
||||
setConnectionError(`Connection failed: ${error.message}`);
|
||||
setIsFatalError(true);
|
||||
setConnecting(directory, tabId, false);
|
||||
setTabLifecycle(directory, tabId, 'exited');
|
||||
setTabSessionId(directory, tabId, null);
|
||||
disconnectStream();
|
||||
},
|
||||
},
|
||||
streamOptions
|
||||
@@ -349,7 +357,7 @@ export const TerminalView: React.FC = () => {
|
||||
activeTerminalIdRef.current = null;
|
||||
};
|
||||
},
|
||||
[appendToBuffer, disconnectStream, focusTerminalWhenWindowActive, setConnecting, setTabSessionId, terminal]
|
||||
[appendToBuffer, disconnectStream, focusTerminalWhenWindowActive, setConnecting, setTabLifecycle, setTabSessionId, terminal]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -389,6 +397,7 @@ export const TerminalView: React.FC = () => {
|
||||
|
||||
const tab = state.tabs.find((t) => t.id === tabId) ?? state.tabs[0];
|
||||
let terminalId = tab?.terminalSessionId ?? null;
|
||||
const terminalLifecycle = tab?.lifecycle ?? 'idle';
|
||||
const isActionTab = Boolean(tab?.label?.startsWith('Action:'));
|
||||
const hasBufferedOutput = (tab?.bufferLength ?? 0) > 0 || (tab?.bufferChunks?.length ?? 0) > 0;
|
||||
|
||||
@@ -402,6 +411,11 @@ export const TerminalView: React.FC = () => {
|
||||
Boolean(terminalId) && rehydratedTerminalIdsRef.current.has(terminalId as string);
|
||||
|
||||
if (!terminalId) {
|
||||
if (terminalLifecycle === 'exited') {
|
||||
setConnecting(directory, tabId, false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isActionTab && hasBufferedOutput) {
|
||||
setConnecting(directory, tabId, false);
|
||||
return;
|
||||
@@ -409,6 +423,7 @@ export const TerminalView: React.FC = () => {
|
||||
|
||||
setConnectionError(null);
|
||||
setIsFatalError(false);
|
||||
setIsReconnectPending(false);
|
||||
setConnecting(directory, tabId, true);
|
||||
try {
|
||||
const size = lastViewportSizeRef.current;
|
||||
@@ -440,6 +455,7 @@ export const TerminalView: React.FC = () => {
|
||||
: 'Failed to start terminal session'
|
||||
);
|
||||
setIsFatalError(true);
|
||||
setIsReconnectPending(false);
|
||||
setConnecting(directory, tabId, false);
|
||||
}
|
||||
return;
|
||||
@@ -476,12 +492,14 @@ export const TerminalView: React.FC = () => {
|
||||
hasActiveContext,
|
||||
effectiveDirectory,
|
||||
terminalSessionId,
|
||||
terminalLifecycle,
|
||||
activeTabId,
|
||||
hasOpenedTerminalViewport,
|
||||
enableTabs,
|
||||
terminalHydrated,
|
||||
ensureDirectory,
|
||||
setConnecting,
|
||||
setTabLifecycle,
|
||||
setTabSessionId,
|
||||
startStream,
|
||||
disconnectStream,
|
||||
@@ -520,6 +538,7 @@ export const TerminalView: React.FC = () => {
|
||||
setIsRestarting(true);
|
||||
setConnectionError(null);
|
||||
setIsFatalError(false);
|
||||
setIsReconnectPending(false);
|
||||
|
||||
disconnectStream();
|
||||
|
||||
@@ -528,6 +547,7 @@ export const TerminalView: React.FC = () => {
|
||||
} catch (error) {
|
||||
setConnectionError(error instanceof Error ? error.message : 'Failed to restart terminal');
|
||||
setIsFatalError(true);
|
||||
setIsReconnectPending(false);
|
||||
} finally {
|
||||
setIsRestarting(false);
|
||||
}
|
||||
@@ -544,6 +564,7 @@ export const TerminalView: React.FC = () => {
|
||||
setActiveTab(effectiveDirectory, tabId);
|
||||
setConnectionError(null);
|
||||
setIsFatalError(false);
|
||||
setIsReconnectPending(false);
|
||||
disconnectStream();
|
||||
}, [createTab, disconnectStream, effectiveDirectory, setActiveTab]);
|
||||
|
||||
@@ -553,6 +574,7 @@ export const TerminalView: React.FC = () => {
|
||||
setActiveTab(effectiveDirectory, tabId);
|
||||
setConnectionError(null);
|
||||
setIsFatalError(false);
|
||||
setIsReconnectPending(false);
|
||||
disconnectStream();
|
||||
},
|
||||
[disconnectStream, effectiveDirectory, setActiveTab]
|
||||
@@ -568,6 +590,7 @@ export const TerminalView: React.FC = () => {
|
||||
|
||||
setConnectionError(null);
|
||||
setIsFatalError(false);
|
||||
setIsReconnectPending(false);
|
||||
void closeTab(effectiveDirectory, tabId);
|
||||
},
|
||||
[activeTabId, closeTab, disconnectStream, effectiveDirectory]
|
||||
@@ -576,7 +599,7 @@ export const TerminalView: React.FC = () => {
|
||||
|
||||
const handleViewportInput = React.useCallback(
|
||||
(data: string) => {
|
||||
if (!data) {
|
||||
if (!data || isReconnectPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -602,7 +625,9 @@ export const TerminalView: React.FC = () => {
|
||||
if (!terminalId) return;
|
||||
|
||||
void terminal.sendInput(terminalId, payload).catch((error) => {
|
||||
setConnectionError(error instanceof Error ? error.message : 'Failed to send input');
|
||||
if (!isReconnectPending) {
|
||||
setConnectionError(error instanceof Error ? error.message : 'Failed to send input');
|
||||
}
|
||||
});
|
||||
|
||||
if (modifierConsumed) {
|
||||
@@ -610,7 +635,7 @@ export const TerminalView: React.FC = () => {
|
||||
terminalControllerRef.current?.focus();
|
||||
}
|
||||
},
|
||||
[activeModifier, setActiveModifier, terminal]
|
||||
[activeModifier, isReconnectPending, setActiveModifier, terminal]
|
||||
);
|
||||
|
||||
const handleViewportResize = React.useCallback(
|
||||
@@ -855,7 +880,7 @@ export const TerminalView: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const quickKeysDisabled = !terminalSessionId || isConnecting || isRestarting;
|
||||
const quickKeysDisabled = !terminalSessionId || isConnecting || isRestarting || isReconnectPending;
|
||||
const shouldRenderViewport = isMobile ? isTerminalVisible : hasOpenedTerminalViewport;
|
||||
const quickKeysControls = (
|
||||
<>
|
||||
@@ -1075,7 +1100,7 @@ export const TerminalView: React.FC = () => {
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
{connectionError && (
|
||||
{!isReconnectPending && connectionError && (
|
||||
<div className="absolute inset-x-0 bottom-0 bg-[var(--status-error-background)] px-3 py-2 text-xs text-[var(--status-error-foreground)] flex items-center justify-between gap-2">
|
||||
<span>{connectionError}</span>
|
||||
{isFatalError && isMobile && (
|
||||
|
||||
@@ -29,20 +29,23 @@ export interface RetryPolicy {
|
||||
maxDelayMs: number;
|
||||
}
|
||||
|
||||
export interface TerminalTransportCapability {
|
||||
preferred?: 'ws' | 'http' | 'sse';
|
||||
transports?: Array<'ws' | 'http' | 'sse'>;
|
||||
ws?: {
|
||||
path: string;
|
||||
v?: number;
|
||||
enc?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface TerminalSession {
|
||||
sessionId: string;
|
||||
cols: number;
|
||||
rows: number;
|
||||
capabilities?: {
|
||||
input?: {
|
||||
preferred?: 'ws' | 'http';
|
||||
transports?: Array<'ws' | 'http'>;
|
||||
ws?: {
|
||||
path: string;
|
||||
v?: number;
|
||||
enc?: string;
|
||||
};
|
||||
};
|
||||
input?: TerminalTransportCapability;
|
||||
stream?: TerminalTransportCapability;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+501
-232
File diff suppressed because it is too large
Load Diff
@@ -9,9 +9,12 @@ export interface TerminalChunk {
|
||||
data: string;
|
||||
}
|
||||
|
||||
export type TerminalTabLifecycle = 'idle' | 'running' | 'exited';
|
||||
|
||||
export type TerminalTab = {
|
||||
id: string;
|
||||
terminalSessionId: string | null;
|
||||
lifecycle: TerminalTabLifecycle;
|
||||
label: string;
|
||||
bufferChunks: TerminalChunk[];
|
||||
bufferLength: number;
|
||||
@@ -40,6 +43,7 @@ interface TerminalStore {
|
||||
closeTab: (directory: string, tabId: string) => Promise<void>;
|
||||
|
||||
setTabSessionId: (directory: string, tabId: string, sessionId: string | null) => void;
|
||||
setTabLifecycle: (directory: string, tabId: string, lifecycle: TerminalTabLifecycle) => void;
|
||||
setConnecting: (directory: string, tabId: string, isConnecting: boolean) => void;
|
||||
appendToBuffer: (directory: string, tabId: string, chunk: string) => void;
|
||||
clearBuffer: (directory: string, tabId: string) => void;
|
||||
@@ -52,7 +56,7 @@ const TERMINAL_BUFFER_LIMIT = 1_000_000;
|
||||
const TERMINAL_STORE_NAME = 'terminal-store';
|
||||
let hydrationListenerAttached = false;
|
||||
|
||||
type PersistedTerminalTab = Pick<TerminalTab, 'id' | 'label' | 'terminalSessionId' | 'createdAt'>;
|
||||
type PersistedTerminalTab = Pick<TerminalTab, 'id' | 'label' | 'terminalSessionId' | 'lifecycle' | 'createdAt'>;
|
||||
|
||||
type PersistedDirectoryTerminalState = {
|
||||
tabs: PersistedTerminalTab[];
|
||||
@@ -85,6 +89,7 @@ function normalizeDirectory(dir: string): string {
|
||||
const createEmptyTab = (id: string, label: string): TerminalTab => ({
|
||||
id,
|
||||
terminalSessionId: null,
|
||||
lifecycle: 'idle',
|
||||
label,
|
||||
bufferChunks: [],
|
||||
bufferLength: 0,
|
||||
@@ -298,9 +303,14 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
const tab = existing.tabs[idx];
|
||||
const shouldResetBuffer = sessionId !== null && tab.terminalSessionId !== sessionId;
|
||||
|
||||
const nextLifecycle = sessionId
|
||||
? 'running'
|
||||
: (tab.terminalSessionId ? 'exited' : tab.lifecycle);
|
||||
|
||||
const nextTab: TerminalTab = {
|
||||
...tab,
|
||||
terminalSessionId: sessionId,
|
||||
lifecycle: nextLifecycle,
|
||||
isConnecting: false,
|
||||
...(shouldResetBuffer ? { bufferChunks: [], bufferLength: 0 } : {}),
|
||||
};
|
||||
@@ -312,6 +322,27 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
});
|
||||
},
|
||||
|
||||
setTabLifecycle: (directory: string, tabId: string, lifecycle: TerminalTabLifecycle) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
const newSessions = new Map(state.sessions);
|
||||
const existing = newSessions.get(key);
|
||||
if (!existing) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const idx = findTabIndex(existing, tabId);
|
||||
if (idx < 0) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const nextTabs = [...existing.tabs];
|
||||
nextTabs[idx] = { ...nextTabs[idx], lifecycle, isConnecting: false };
|
||||
newSessions.set(key, { ...existing, tabs: nextTabs });
|
||||
return { sessions: newSessions };
|
||||
});
|
||||
},
|
||||
|
||||
setConnecting: (directory: string, tabId: string, isConnecting: boolean) => {
|
||||
const key = normalizeDirectory(directory);
|
||||
set((state) => {
|
||||
@@ -428,6 +459,7 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
id: tab.id,
|
||||
label: tab.label,
|
||||
terminalSessionId: tab.terminalSessionId,
|
||||
lifecycle: tab.lifecycle,
|
||||
createdAt: tab.createdAt,
|
||||
})),
|
||||
},
|
||||
@@ -474,13 +506,21 @@ export const useTerminalStore = create<TerminalStore>()(
|
||||
maxTabNum = Math.max(maxTabNum, num);
|
||||
}
|
||||
|
||||
const terminalSessionId =
|
||||
typeof rawTab.terminalSessionId === 'string' || rawTab.terminalSessionId === null
|
||||
? (rawTab.terminalSessionId as string | null)
|
||||
: null;
|
||||
const lifecycleRaw = rawTab.lifecycle;
|
||||
const lifecycle =
|
||||
lifecycleRaw === 'idle' || lifecycleRaw === 'running' || lifecycleRaw === 'exited'
|
||||
? lifecycleRaw
|
||||
: (terminalSessionId ? 'running' : 'idle');
|
||||
|
||||
tabs.push({
|
||||
id,
|
||||
label: typeof rawTab.label === 'string' ? rawTab.label : 'Terminal',
|
||||
terminalSessionId:
|
||||
typeof rawTab.terminalSessionId === 'string' || rawTab.terminalSessionId === null
|
||||
? (rawTab.terminalSessionId as string | null)
|
||||
: null,
|
||||
terminalSessionId,
|
||||
lifecycle,
|
||||
createdAt: typeof rawTab.createdAt === 'number' ? rawTab.createdAt : Date.now(),
|
||||
bufferChunks: [],
|
||||
bufferLength: 0,
|
||||
|
||||
Reference in New Issue
Block a user