2026-09-05 03:04:36 -06:00
|
|
|
import type { CreateTerminalOptions, TerminalError, TerminalHandlers, TerminalServerSession, TerminalSession, TerminalSessionPurpose, TerminalShellOption, TerminalStreamEvent } from './api/types';
|
2026-09-06 02:14:18 +03:00
|
|
|
import type { TerminalChunkSize } from '@/stores/useTerminalStore';
|
2026-07-08 03:44:02 +03:00
|
|
|
import { openRuntimeWebSocket } from './relay/runtime-socket';
|
2026-09-05 12:05:39 +03:00
|
|
|
import type { RelayTunnelSocketMessageEvent, RelayTunnelWebSocket } from './relay/tunnel-client';
|
2026-07-17 13:17:21 +03:00
|
|
|
import { runtimeFetch } from './runtime-fetch';
|
|
|
|
|
import { getRuntimeUrlResolver } from './runtime-url';
|
2026-08-03 12:29:32 +03:00
|
|
|
import { clearRuntimeUrlAuthToken, refreshRuntimeUrlAuthToken } from './runtime-auth';
|
2026-07-17 13:17:21 +03:00
|
|
|
import { isTerminalShell } from './terminalShell';
|
2026-09-05 03:04:36 -06:00
|
|
|
import { z } from 'zod';
|
2026-07-17 13:17:21 +03:00
|
|
|
|
2026-09-05 12:05:39 +03:00
|
|
|
type ClientMessage =
|
|
|
|
|
| { t: 'hello' | 'ping'; v: 3 }
|
|
|
|
|
| { t: 'attach' | 'detach'; v: 3; s: string }
|
|
|
|
|
| { t: 'write'; v: 3; s: string; d: string };
|
2026-07-17 13:17:21 +03:00
|
|
|
type Subscriber = { handlers: TerminalHandlers; lastSequence: number };
|
|
|
|
|
type TerminalProjection = {
|
|
|
|
|
sequence: number;
|
|
|
|
|
history: string;
|
2026-09-06 02:14:18 +03:00
|
|
|
/** Current PTY size: what the server reported at attach, updated by every accepted resize. */
|
|
|
|
|
cols?: number;
|
|
|
|
|
rows?: number;
|
2026-07-17 13:17:21 +03:00
|
|
|
status: TerminalStreamEvent['status'];
|
2026-09-05 03:04:36 -06:00
|
|
|
mode?: TerminalSession['mode'];
|
|
|
|
|
purpose?: TerminalSessionPurpose;
|
2026-04-02 00:31:21 +08:00
|
|
|
exitCode?: number;
|
|
|
|
|
signal?: number | null;
|
2026-07-17 13:17:21 +03:00
|
|
|
runtime?: TerminalStreamEvent['runtime'];
|
2026-04-02 00:31:21 +08:00
|
|
|
ptyBackend?: string;
|
|
|
|
|
};
|
2026-07-17 13:17:21 +03:00
|
|
|
const TAG = 1;
|
|
|
|
|
const MAX_PROJECTION_BYTES = 512 * 1024;
|
|
|
|
|
const SOCKET_CONNECTING = 0;
|
|
|
|
|
const SOCKET_OPEN = 1;
|
2026-07-30 13:48:24 +03:00
|
|
|
/**
|
|
|
|
|
* Switching terminal tabs detaches the old terminal before attaching the new one,
|
|
|
|
|
* which momentarily leaves zero subscribers. Closing the socket there forced a
|
|
|
|
|
* token refresh, a fresh upgrade and a full snapshot replay on every switch, so
|
|
|
|
|
* hold the idle socket briefly and reuse it instead.
|
|
|
|
|
*/
|
|
|
|
|
const IDLE_SOCKET_GRACE_MS = 15_000;
|
2026-07-17 13:17:21 +03:00
|
|
|
const encoder = new TextEncoder();
|
|
|
|
|
const decoder = new TextDecoder();
|
2026-09-05 03:04:36 -06:00
|
|
|
const terminalModeSchema = z.enum(['interactive', 'command']);
|
|
|
|
|
const terminalStatusSchema = z.enum(['running', 'exited', 'error']);
|
|
|
|
|
const terminalRuntimeSchema = z.enum(['node', 'bun']);
|
|
|
|
|
type TerminalSessionPurposeInput =
|
|
|
|
|
| TerminalSessionPurpose
|
|
|
|
|
| { type: 'project-action'; actionId: string; executionId?: string }
|
|
|
|
|
| null
|
|
|
|
|
| undefined;
|
|
|
|
|
type TerminalSessionInput = {
|
|
|
|
|
sessionId?: string;
|
|
|
|
|
cols?: number;
|
|
|
|
|
rows?: number;
|
|
|
|
|
status?: 'running' | 'exited' | 'error';
|
|
|
|
|
mode?: 'interactive' | 'command';
|
|
|
|
|
purpose?: TerminalSessionPurposeInput;
|
|
|
|
|
} | null | undefined;
|
|
|
|
|
const terminalSessionPurposeSchema = z.discriminatedUnion('type', [
|
|
|
|
|
z.object({ type: z.literal('terminal') }),
|
|
|
|
|
z.object({ type: z.literal('project-action'), actionId: z.string(), executionId: z.string() }),
|
|
|
|
|
]);
|
|
|
|
|
const terminalSessionSchema = z.object({
|
|
|
|
|
sessionId: z.string(),
|
|
|
|
|
cols: z.number(),
|
|
|
|
|
rows: z.number(),
|
|
|
|
|
status: terminalStatusSchema,
|
|
|
|
|
mode: terminalModeSchema.optional(),
|
|
|
|
|
purpose: terminalSessionPurposeSchema.optional(),
|
|
|
|
|
});
|
|
|
|
|
const terminalServerSessionSchema = z.object({
|
|
|
|
|
sessionId: z.string(),
|
|
|
|
|
cwd: z.string(),
|
|
|
|
|
status: z.enum(['running', 'exited']),
|
|
|
|
|
createdAt: z.number().nullable().optional().transform((value) => value ?? null),
|
|
|
|
|
mode: terminalModeSchema.optional(),
|
|
|
|
|
purpose: terminalSessionPurposeSchema.optional(),
|
|
|
|
|
});
|
2026-07-17 13:17:21 +03:00
|
|
|
|
2026-09-05 12:05:39 +03:00
|
|
|
const terminalMessageMetadata = {
|
|
|
|
|
mode: terminalModeSchema.optional(),
|
|
|
|
|
purpose: terminalSessionPurposeSchema.optional(),
|
|
|
|
|
};
|
|
|
|
|
const terminalMessageSchema = z.discriminatedUnion('t', [
|
|
|
|
|
z.object({ t: z.literal('hello') }),
|
|
|
|
|
z.object({ t: z.literal('pong') }),
|
|
|
|
|
z.object({ t: z.literal('error'), s: z.string().optional(), message: z.string().optional(), code: z.string().optional(), fatal: z.boolean().optional() }),
|
|
|
|
|
z.object({
|
|
|
|
|
t: z.literal('snapshot'), s: z.string(), q: z.number().int().nonnegative().default(0),
|
|
|
|
|
history: z.string().default(''), status: terminalStatusSchema,
|
2026-09-06 02:14:18 +03:00
|
|
|
cols: z.number().int().positive().optional(), rows: z.number().int().positive().optional(),
|
2026-09-05 12:05:39 +03:00
|
|
|
exitCode: z.number().nullish().transform(value => value ?? undefined), signal: z.number().nullable().optional(),
|
|
|
|
|
runtime: terminalRuntimeSchema.optional(), ptyBackend: z.string().optional(), ...terminalMessageMetadata,
|
|
|
|
|
}),
|
|
|
|
|
z.object({ t: z.literal('output'), s: z.string(), q: z.number().int().nonnegative(), d: z.string(), r: z.string().optional() }),
|
|
|
|
|
z.object({ t: z.literal('exit'), s: z.string(), q: z.number().int().nonnegative(), exitCode: z.number().nullish().transform(value => value ?? undefined), signal: z.number().nullable().optional() }),
|
|
|
|
|
z.object({ t: z.literal('restarted'), s: z.string(), q: z.number().int().nonnegative(), history: z.string().default(''), ...terminalMessageMetadata }),
|
|
|
|
|
]);
|
|
|
|
|
type TerminalMessage = z.infer<typeof terminalMessageSchema>;
|
|
|
|
|
|
|
|
|
|
const encode = (message: ClientMessage): Uint8Array => {
|
2026-07-17 13:17:21 +03:00
|
|
|
const payload = encoder.encode(JSON.stringify(message));
|
|
|
|
|
const frame = new Uint8Array(payload.length + 1);
|
|
|
|
|
frame[0] = TAG;
|
|
|
|
|
frame.set(payload, 1);
|
|
|
|
|
return frame;
|
2026-02-08 15:39:22 +02:00
|
|
|
};
|
|
|
|
|
|
2026-09-05 12:05:39 +03:00
|
|
|
const decode = (data: RelayTunnelSocketMessageEvent['data']): TerminalMessage | null => {
|
|
|
|
|
let bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : encoder.encode(data);
|
2026-07-17 13:17:21 +03:00
|
|
|
if (bytes[0] === TAG) bytes = bytes.subarray(1);
|
2026-09-05 12:05:39 +03:00
|
|
|
try { return terminalMessageSchema.safeParse(JSON.parse(decoder.decode(bytes))).data ?? null; } catch { return null; }
|
2026-02-08 15:39:22 +02:00
|
|
|
};
|
|
|
|
|
|
2026-09-05 21:26:21 +03:00
|
|
|
/**
|
|
|
|
|
* Server error code for a terminal request whose working directory no longer
|
|
|
|
|
* exists (a deleted worktree). Mirrors `TERMINAL_CWD_MISSING_CODE` in
|
|
|
|
|
* `packages/web/server/lib/terminal/runtime.js`.
|
|
|
|
|
*/
|
|
|
|
|
const TERMINAL_CWD_MISSING_CODE = 'TERMINAL_CWD_MISSING';
|
|
|
|
|
|
|
|
|
|
export class TerminalRequestError extends Error {
|
|
|
|
|
readonly code: string | null;
|
|
|
|
|
|
|
|
|
|
constructor(message: string, code: string | null) {
|
|
|
|
|
super(message);
|
|
|
|
|
this.name = 'TerminalRequestError';
|
|
|
|
|
this.code = code;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-06 02:14:18 +03:00
|
|
|
/** The PTY size a snapshot's history was drawn for, when the server reported one. */
|
|
|
|
|
export const terminalSnapshotSize = (event: Pick<TerminalStreamEvent, 'cols' | 'rows'>): TerminalChunkSize | undefined =>
|
|
|
|
|
event.cols !== undefined && event.rows !== undefined ? { cols: event.cols, rows: event.rows } : undefined;
|
|
|
|
|
|
2026-09-05 21:26:21 +03:00
|
|
|
export const isTerminalCwdMissingError = (error: unknown): boolean =>
|
|
|
|
|
error instanceof TerminalRequestError && error.code === TERMINAL_CWD_MISSING_CODE;
|
|
|
|
|
|
|
|
|
|
const terminalErrorBodySchema = z.object({ error: z.string().optional(), code: z.string().optional() });
|
|
|
|
|
|
2026-07-17 13:17:21 +03:00
|
|
|
const responseError = async (response: Response, fallback: string): Promise<Error> => {
|
2026-09-05 21:26:21 +03:00
|
|
|
const body = terminalErrorBodySchema.safeParse(await response.json().catch(() => null)).data;
|
|
|
|
|
return new TerminalRequestError(body?.error ?? fallback, body?.code ?? null);
|
2026-02-08 15:39:22 +02:00
|
|
|
};
|
|
|
|
|
|
2026-07-17 13:17:21 +03:00
|
|
|
const trimProjection = (value: string): string => {
|
|
|
|
|
const bytes = encoder.encode(value);
|
|
|
|
|
if (bytes.byteLength <= MAX_PROJECTION_BYTES) return value;
|
|
|
|
|
let start = bytes.byteLength - MAX_PROJECTION_BYTES;
|
|
|
|
|
while (start < bytes.byteLength && (bytes[start] & 0xc0) === 0x80) start += 1;
|
|
|
|
|
return decoder.decode(bytes.subarray(start));
|
2026-02-08 15:39:22 +02:00
|
|
|
};
|
|
|
|
|
|
2026-09-05 03:04:36 -06:00
|
|
|
const terminalSessionListSchema = z.object({ sessions: z.array(z.unknown()) });
|
|
|
|
|
|
|
|
|
|
export const parseTerminalSessionPurpose = (value: TerminalSessionPurposeInput): TerminalSessionPurpose | undefined => {
|
|
|
|
|
return terminalSessionPurposeSchema.safeParse(value).data;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const parseTerminalSession = (value: TerminalSessionInput): TerminalSession | null => {
|
|
|
|
|
return terminalSessionSchema.safeParse(value).data ?? null;
|
|
|
|
|
};
|
|
|
|
|
|
2026-07-17 13:17:21 +03:00
|
|
|
type TerminalTransportDependencies = {
|
|
|
|
|
refreshAuth: () => Promise<unknown>;
|
|
|
|
|
openSocket: () => RelayTunnelWebSocket;
|
2026-08-03 12:29:32 +03:00
|
|
|
clearUrlAuthToken?: () => void;
|
2026-04-02 00:31:21 +08:00
|
|
|
};
|
|
|
|
|
|
2026-07-17 13:17:21 +03:00
|
|
|
export class TerminalTransport {
|
2026-07-08 03:44:02 +03:00
|
|
|
private socket: RelayTunnelWebSocket | null = null;
|
2026-07-17 13:17:21 +03:00
|
|
|
private opening: Promise<void> | null = null;
|
|
|
|
|
private subscribers = new Map<string, Set<Subscriber>>();
|
|
|
|
|
private projections = new Map<string, TerminalProjection>();
|
|
|
|
|
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
|
private keepaliveTimer: ReturnType<typeof setInterval> | null = null;
|
2026-07-30 13:48:24 +03:00
|
|
|
private idleCloseTimer: ReturnType<typeof setTimeout> | null = null;
|
2026-07-17 13:17:21 +03:00
|
|
|
private failures = 0;
|
|
|
|
|
private wakeCleanup: (() => void) | null = null;
|
|
|
|
|
private generation = 0;
|
|
|
|
|
private disposed = false;
|
|
|
|
|
|
|
|
|
|
constructor(private readonly dependencies: TerminalTransportDependencies = {
|
|
|
|
|
refreshAuth: refreshRuntimeUrlAuthToken,
|
|
|
|
|
openSocket: () => openRuntimeWebSocket(getRuntimeUrlResolver().websocket('/api/terminal/ws')),
|
2026-08-03 12:29:32 +03:00
|
|
|
clearUrlAuthToken: clearRuntimeUrlAuthToken,
|
2026-07-17 13:17:21 +03:00
|
|
|
}) {}
|
|
|
|
|
|
|
|
|
|
subscribe(sessionId: string, handlers: TerminalHandlers): () => void {
|
2026-07-30 13:48:24 +03:00
|
|
|
this.cancelIdleClose();
|
2026-07-17 13:17:21 +03:00
|
|
|
const subscriber = { handlers, lastSequence: -1 };
|
|
|
|
|
const set = this.subscribers.get(sessionId) ?? new Set<Subscriber>();
|
|
|
|
|
const first = set.size === 0;
|
|
|
|
|
set.add(subscriber);
|
|
|
|
|
this.subscribers.set(sessionId, set);
|
|
|
|
|
const projection = this.projections.get(sessionId);
|
|
|
|
|
if (projection) {
|
|
|
|
|
subscriber.lastSequence = projection.sequence;
|
2026-09-06 02:14:18 +03:00
|
|
|
handlers.onEvent({ type: 'snapshot', sequence: projection.sequence, data: projection.history, cols: projection.cols, rows: projection.rows, status: projection.status, mode: projection.mode, purpose: projection.purpose, exitCode: projection.exitCode, signal: projection.signal, runtime: projection.runtime, ptyBackend: projection.ptyBackend });
|
2026-07-17 13:17:21 +03:00
|
|
|
}
|
|
|
|
|
const socketWasOpen = this.socket?.readyState === SOCKET_OPEN;
|
2026-08-03 12:29:32 +03:00
|
|
|
this.ensureConnected().then(() => {
|
|
|
|
|
const current = this.subscribers.get(sessionId);
|
|
|
|
|
if (first && socketWasOpen && current === set && current.size > 0) {
|
|
|
|
|
this.send({ t: 'attach', v: 3, s: sessionId });
|
|
|
|
|
}
|
|
|
|
|
}).catch((error) => {
|
|
|
|
|
if (!set.has(subscriber)) return;
|
2026-07-17 13:17:21 +03:00
|
|
|
handlers.onError?.(error, false);
|
|
|
|
|
this.scheduleReconnect();
|
|
|
|
|
});
|
2026-04-02 00:31:21 +08:00
|
|
|
return () => {
|
2026-07-17 13:17:21 +03:00
|
|
|
const current = this.subscribers.get(sessionId);
|
|
|
|
|
current?.delete(subscriber);
|
|
|
|
|
if (current?.size === 0) {
|
|
|
|
|
this.subscribers.delete(sessionId);
|
|
|
|
|
this.projections.delete(sessionId);
|
|
|
|
|
this.send({ t: 'detach', v: 3, s: sessionId });
|
2026-04-02 00:31:21 +08:00
|
|
|
}
|
2026-07-17 13:17:21 +03:00
|
|
|
if (this.subscribers.size === 0) {
|
|
|
|
|
this.cancelReconnect();
|
2026-08-03 12:29:32 +03:00
|
|
|
this.failures = 0;
|
2026-07-30 13:48:24 +03:00
|
|
|
if (this.socket?.readyState === SOCKET_OPEN) {
|
|
|
|
|
// Healthy socket: hold it briefly so a tab switch can reattach to it.
|
|
|
|
|
this.scheduleIdleClose();
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
// Nothing to reuse, so abandon any dial that is still in flight.
|
|
|
|
|
this.generation += 1;
|
2026-08-03 12:29:32 +03:00
|
|
|
this.opening = null;
|
2026-07-17 13:17:21 +03:00
|
|
|
this.closeSocket();
|
2026-05-08 16:27:27 -04:00
|
|
|
}
|
2026-04-02 00:31:21 +08:00
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 13:17:21 +03:00
|
|
|
async write(sessionId: string, data: string): Promise<void> {
|
|
|
|
|
if (!data) return;
|
|
|
|
|
await this.ensureConnected();
|
|
|
|
|
if (this.send({ t: 'write', v: 3, s: sessionId, d: data })) return;
|
|
|
|
|
this.closeSocket();
|
|
|
|
|
await this.ensureConnected();
|
|
|
|
|
if (!this.send({ t: 'write', v: 3, s: sessionId, d: data })) throw new Error('Terminal connection is unavailable');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
dispose(): void {
|
|
|
|
|
this.disposed = true;
|
|
|
|
|
this.generation += 1;
|
2026-08-03 12:29:32 +03:00
|
|
|
this.opening = null;
|
2026-07-17 13:17:21 +03:00
|
|
|
this.subscribers.clear();
|
|
|
|
|
this.projections.clear();
|
|
|
|
|
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
|
|
|
this.reconnectTimer = null;
|
2026-07-30 13:48:24 +03:00
|
|
|
this.cancelIdleClose();
|
2026-07-17 13:17:21 +03:00
|
|
|
this.wakeCleanup?.();
|
|
|
|
|
this.wakeCleanup = null;
|
|
|
|
|
this.closeSocket();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
forget(sessionId: string): void {
|
|
|
|
|
this.projections.delete(sessionId);
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-06 02:14:18 +03:00
|
|
|
/**
|
|
|
|
|
* Records a resize the server accepted, so a projection snapshot replayed to
|
|
|
|
|
* a later subscriber (tab switch, remount) still names the size the
|
|
|
|
|
* terminal's current screen is drawn for.
|
|
|
|
|
*/
|
|
|
|
|
noteResize(sessionId: string, cols: number, rows: number): void {
|
|
|
|
|
const projection = this.projections.get(sessionId);
|
|
|
|
|
if (!projection) return;
|
|
|
|
|
this.projections.set(sessionId, { ...projection, cols, rows });
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-17 13:17:21 +03:00
|
|
|
private async ensureConnected(): Promise<void> {
|
|
|
|
|
if (this.disposed) throw new Error('Terminal runtime changed');
|
|
|
|
|
if (this.socket?.readyState === SOCKET_OPEN) return;
|
2026-08-03 12:29:32 +03:00
|
|
|
if (this.opening) {
|
2026-07-17 13:17:21 +03:00
|
|
|
await this.opening;
|
|
|
|
|
if (this.socket?.readyState === SOCKET_OPEN) return;
|
|
|
|
|
return this.ensureConnected();
|
|
|
|
|
}
|
|
|
|
|
const generation = this.generation;
|
|
|
|
|
const opening = (async () => {
|
|
|
|
|
await this.dependencies.refreshAuth();
|
|
|
|
|
if (generation !== this.generation || this.disposed) throw new Error('Terminal runtime changed');
|
|
|
|
|
await new Promise<void>((resolve, reject) => {
|
2026-08-03 12:29:32 +03:00
|
|
|
let settled = false;
|
|
|
|
|
let opened = false;
|
|
|
|
|
let authInvalidated = false;
|
|
|
|
|
let pendingSocket: RelayTunnelWebSocket | null = null;
|
|
|
|
|
const isCurrentSocket = () => (
|
|
|
|
|
generation === this.generation &&
|
|
|
|
|
!this.disposed &&
|
|
|
|
|
pendingSocket !== null &&
|
|
|
|
|
this.socket === pendingSocket
|
|
|
|
|
);
|
|
|
|
|
const invalidatePreOpenAuth = () => {
|
|
|
|
|
if (authInvalidated || opened || !isCurrentSocket()) return;
|
|
|
|
|
authInvalidated = true;
|
|
|
|
|
this.dependencies.clearUrlAuthToken?.();
|
2026-02-08 15:39:22 +02:00
|
|
|
};
|
2026-08-03 12:29:32 +03:00
|
|
|
const finish = (error?: Error) => {
|
|
|
|
|
if (settled) return;
|
|
|
|
|
settled = true;
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
if (error) reject(error);
|
|
|
|
|
else resolve();
|
2026-04-02 00:31:21 +08:00
|
|
|
};
|
2026-08-03 12:29:32 +03:00
|
|
|
const timeout = setTimeout(() => {
|
|
|
|
|
invalidatePreOpenAuth();
|
|
|
|
|
pendingSocket?.close();
|
|
|
|
|
finish(new Error('Terminal connection timed out'));
|
|
|
|
|
}, 10_000);
|
|
|
|
|
try {
|
|
|
|
|
const socket = this.dependencies.openSocket();
|
|
|
|
|
pendingSocket = socket;
|
|
|
|
|
socket.binaryType = 'arraybuffer';
|
|
|
|
|
this.socket = socket;
|
|
|
|
|
socket.onopen = () => {
|
|
|
|
|
if (!isCurrentSocket()) { socket.close(); finish(new Error('Terminal runtime changed')); return; }
|
|
|
|
|
opened = true;
|
|
|
|
|
this.failures = 0;
|
|
|
|
|
this.send({ t: 'hello', v: 3 });
|
|
|
|
|
for (const sessionId of this.subscribers.keys()) this.send({ t: 'attach', v: 3, s: sessionId });
|
|
|
|
|
this.startKeepalive();
|
|
|
|
|
finish();
|
|
|
|
|
};
|
|
|
|
|
socket.onmessage = (event) => void this.handleMessage(event.data);
|
|
|
|
|
socket.onerror = () => {
|
|
|
|
|
const current = isCurrentSocket();
|
|
|
|
|
if (current) invalidatePreOpenAuth();
|
|
|
|
|
finish(new Error('Terminal WebSocket failed'));
|
|
|
|
|
if (current && this.subscribers.size > 0) this.scheduleReconnect();
|
|
|
|
|
};
|
|
|
|
|
socket.onclose = () => {
|
|
|
|
|
const current = isCurrentSocket();
|
|
|
|
|
if (current) {
|
|
|
|
|
this.stopKeepalive();
|
|
|
|
|
// An upgrade rejected before `open` commonly means the cached
|
|
|
|
|
// URL-scoped auth token is stale. Retrying it reaches the 8s
|
|
|
|
|
// backoff cap instead of minting a fresh token.
|
|
|
|
|
invalidatePreOpenAuth();
|
|
|
|
|
}
|
|
|
|
|
if (this.socket === socket) this.socket = null;
|
|
|
|
|
finish(new Error('Terminal WebSocket closed'));
|
|
|
|
|
if (current && this.subscribers.size > 0) this.scheduleReconnect();
|
|
|
|
|
};
|
|
|
|
|
} catch (error) {
|
|
|
|
|
finish(error instanceof Error ? error : new Error('Terminal WebSocket failed'));
|
2026-07-17 13:17:21 +03:00
|
|
|
if (!this.disposed && this.subscribers.size > 0) this.scheduleReconnect();
|
2026-08-03 12:29:32 +03:00
|
|
|
}
|
2026-07-17 13:17:21 +03:00
|
|
|
});
|
|
|
|
|
})();
|
|
|
|
|
this.opening = opening;
|
2026-04-02 00:31:21 +08:00
|
|
|
try {
|
2026-07-17 13:17:21 +03:00
|
|
|
await opening;
|
|
|
|
|
} finally {
|
|
|
|
|
if (this.opening === opening) {
|
|
|
|
|
this.opening = null;
|
2026-04-02 00:31:21 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-05 12:05:39 +03:00
|
|
|
private handleMessage(raw: RelayTunnelSocketMessageEvent['data']): void {
|
|
|
|
|
const message = decode(raw);
|
2026-07-17 13:17:21 +03:00
|
|
|
if (!message || message.t === 'hello' || message.t === 'pong') return;
|
|
|
|
|
if (message.t === 'error') {
|
2026-09-05 12:05:39 +03:00
|
|
|
const error: TerminalError = new Error(message.message ?? 'Terminal error');
|
|
|
|
|
error.code = message.code;
|
2026-07-17 13:17:21 +03:00
|
|
|
const targets = message.s ? [message.s] : [...this.subscribers.keys()];
|
|
|
|
|
for (const id of targets) for (const sub of this.subscribers.get(id) ?? []) sub.handlers.onError?.(error, message.fatal === true);
|
2026-04-02 00:31:21 +08:00
|
|
|
return;
|
|
|
|
|
}
|
2026-07-17 13:17:21 +03:00
|
|
|
if (!message.s) return;
|
|
|
|
|
const subscribers = this.subscribers.get(message.s);
|
|
|
|
|
if (!subscribers) return;
|
|
|
|
|
if (message.t === 'snapshot') {
|
|
|
|
|
const projection: TerminalProjection = {
|
2026-09-05 03:04:36 -06:00
|
|
|
sequence: message.q ?? 0,
|
|
|
|
|
history: message.history ?? '',
|
2026-09-06 02:14:18 +03:00
|
|
|
cols: message.cols,
|
|
|
|
|
rows: message.rows,
|
2026-09-05 12:05:39 +03:00
|
|
|
status: message.status,
|
|
|
|
|
mode: message.mode,
|
|
|
|
|
purpose: message.purpose,
|
2026-09-05 03:04:36 -06:00
|
|
|
exitCode: message.exitCode,
|
|
|
|
|
signal: message.signal ?? null,
|
2026-09-05 12:05:39 +03:00
|
|
|
runtime: message.runtime,
|
2026-09-05 03:04:36 -06:00
|
|
|
ptyBackend: message.ptyBackend,
|
2026-07-17 13:17:21 +03:00
|
|
|
};
|
|
|
|
|
this.projections.set(message.s, projection);
|
|
|
|
|
for (const sub of subscribers) {
|
|
|
|
|
sub.lastSequence = projection.sequence;
|
2026-09-06 02:14:18 +03:00
|
|
|
sub.handlers.onEvent({ type: 'snapshot', sequence: projection.sequence, data: projection.history, cols: projection.cols, rows: projection.rows, status: projection.status, mode: projection.mode, purpose: projection.purpose, exitCode: projection.exitCode, signal: projection.signal, runtime: projection.runtime, ptyBackend: projection.ptyBackend });
|
2026-02-08 15:39:22 +02:00
|
|
|
}
|
2025-12-07 19:32:53 +02:00
|
|
|
return;
|
|
|
|
|
}
|
2026-09-05 12:05:39 +03:00
|
|
|
|
2026-07-17 13:17:21 +03:00
|
|
|
const previous = this.projections.get(message.s);
|
|
|
|
|
if (previous && message.q > previous.sequence) {
|
2026-09-05 12:05:39 +03:00
|
|
|
if (message.t === 'output') this.projections.set(message.s, { ...previous, sequence: message.q, history: trimProjection(previous.history + (message.r ?? message.d)) });
|
|
|
|
|
else if (message.t === 'exit') this.projections.set(message.s, { ...previous, sequence: message.q, status: 'exited', exitCode: message.exitCode, signal: message.signal ?? null });
|
|
|
|
|
else if (message.t === 'restarted') this.projections.set(message.s, { ...previous, sequence: message.q, history: message.history ?? '', status: 'running', mode: message.mode ?? previous.mode, purpose: message.purpose ?? previous.purpose, exitCode: undefined, signal: null });
|
2026-07-17 13:17:21 +03:00
|
|
|
}
|
|
|
|
|
for (const sub of subscribers) {
|
|
|
|
|
if (message.q <= sub.lastSequence) continue;
|
|
|
|
|
sub.lastSequence = message.q;
|
2026-09-05 12:05:39 +03:00
|
|
|
if (message.t === 'output') sub.handlers.onEvent({ type: 'data', sequence: message.q, data: message.d, replayData: message.r });
|
|
|
|
|
else if (message.t === 'exit') sub.handlers.onEvent({ type: 'exit', sequence: message.q, exitCode: message.exitCode, signal: message.signal ?? null });
|
|
|
|
|
else if (message.t === 'restarted') sub.handlers.onEvent({ type: 'snapshot', sequence: message.q, data: message.history ?? '', status: 'running', mode: message.mode ?? previous?.mode, purpose: message.purpose ?? previous?.purpose });
|
2026-07-17 13:17:21 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-05 12:05:39 +03:00
|
|
|
private send(message: ClientMessage): boolean {
|
2026-07-17 13:17:21 +03:00
|
|
|
if (!this.socket || this.socket.readyState !== SOCKET_OPEN) return false;
|
|
|
|
|
try { this.socket.send(encode(message)); return true; } catch { return false; }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private scheduleReconnect(): void {
|
|
|
|
|
if (this.reconnectTimer || this.disposed || this.subscribers.size === 0) return;
|
|
|
|
|
this.failures += 1;
|
|
|
|
|
const slow = (typeof document !== 'undefined' && document.visibilityState === 'hidden') || (typeof navigator !== 'undefined' && !navigator.onLine);
|
2026-08-03 12:29:32 +03:00
|
|
|
const delay = slow ? 60_000 : Math.min(500 * 2 ** Math.min(this.failures - 1, 10), 8_000);
|
2026-07-17 13:17:21 +03:00
|
|
|
for (const set of this.subscribers.values()) for (const sub of set) sub.handlers.onEvent({ type: 'reconnecting', attempt: this.failures, maxAttempts: Number.POSITIVE_INFINITY });
|
|
|
|
|
const wake = () => {
|
|
|
|
|
if (typeof document !== 'undefined' && document.visibilityState === 'hidden') return;
|
|
|
|
|
if (typeof navigator !== 'undefined' && !navigator.onLine) return;
|
|
|
|
|
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
|
|
|
|
|
this.reconnectTimer = null;
|
|
|
|
|
this.wakeCleanup?.(); this.wakeCleanup = null;
|
|
|
|
|
void this.ensureConnected().catch(() => this.scheduleReconnect());
|
2025-12-07 19:32:53 +02:00
|
|
|
};
|
2026-07-17 13:17:21 +03:00
|
|
|
if (typeof window !== 'undefined') window.addEventListener('online', wake);
|
|
|
|
|
if (typeof document !== 'undefined') document.addEventListener('visibilitychange', wake);
|
|
|
|
|
this.wakeCleanup = () => {
|
|
|
|
|
if (typeof window !== 'undefined') window.removeEventListener('online', wake);
|
|
|
|
|
if (typeof document !== 'undefined') document.removeEventListener('visibilitychange', wake);
|
2025-12-07 19:32:53 +02:00
|
|
|
};
|
2026-07-17 13:17:21 +03:00
|
|
|
this.reconnectTimer = setTimeout(wake, delay);
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
2026-02-08 15:39:22 +02:00
|
|
|
|
2026-07-30 13:48:24 +03:00
|
|
|
private scheduleIdleClose(): void {
|
|
|
|
|
if (this.idleCloseTimer || this.disposed) return;
|
|
|
|
|
this.idleCloseTimer = setTimeout(() => {
|
|
|
|
|
this.idleCloseTimer = null;
|
|
|
|
|
if (this.disposed || this.subscribers.size > 0) return;
|
|
|
|
|
this.generation += 1;
|
2026-08-03 12:29:32 +03:00
|
|
|
this.opening = null;
|
2026-07-30 13:48:24 +03:00
|
|
|
this.closeSocket();
|
|
|
|
|
}, IDLE_SOCKET_GRACE_MS);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private cancelIdleClose(): void {
|
|
|
|
|
if (!this.idleCloseTimer) return;
|
|
|
|
|
clearTimeout(this.idleCloseTimer);
|
|
|
|
|
this.idleCloseTimer = null;
|
|
|
|
|
}
|
|
|
|
|
|
2026-08-08 17:26:59 +03:00
|
|
|
private startKeepalive(): void { this.stopKeepalive(); this.keepaliveTimer = setInterval(() => this.send({ t: 'ping', v: 3 }), 45_000); }
|
2026-07-17 13:17:21 +03:00
|
|
|
private stopKeepalive(): void { if (this.keepaliveTimer) clearInterval(this.keepaliveTimer); this.keepaliveTimer = null; }
|
|
|
|
|
private cancelReconnect(): void { if (this.reconnectTimer) clearTimeout(this.reconnectTimer); this.reconnectTimer = null; this.wakeCleanup?.(); this.wakeCleanup = null; }
|
|
|
|
|
private closeSocket(): void { this.stopKeepalive(); const socket = this.socket; this.socket = null; if (socket && (socket.readyState === SOCKET_CONNECTING || socket.readyState === SOCKET_OPEN)) socket.close(); }
|
2025-12-07 19:32:53 +02:00
|
|
|
}
|
|
|
|
|
|
2026-07-17 13:17:21 +03:00
|
|
|
let transport = new TerminalTransport();
|
2025-12-10 00:25:02 +02:00
|
|
|
|
2026-07-17 13:17:21 +03:00
|
|
|
export async function createTerminalSession(options: CreateTerminalOptions): Promise<TerminalSession> {
|
|
|
|
|
const response = await runtimeFetch('/api/terminal/create', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(options) });
|
|
|
|
|
if (!response.ok) throw await responseError(response, 'Failed to create terminal session');
|
2026-09-05 03:04:36 -06:00
|
|
|
const payload: unknown = await response.json().catch(() => null);
|
|
|
|
|
const parsed = terminalSessionSchema.safeParse(payload).data;
|
|
|
|
|
if (!parsed) throw new Error('Failed to create terminal session');
|
|
|
|
|
return parsed;
|
2025-12-10 00:25:02 +02:00
|
|
|
}
|
2026-08-24 14:28:45 +03:00
|
|
|
export async function listTerminalSessions(cwd: string): Promise<TerminalServerSession[]> {
|
|
|
|
|
const response = await runtimeFetch(`/api/terminal/sessions?cwd=${encodeURIComponent(cwd)}`);
|
|
|
|
|
if (!response.ok) throw await responseError(response, 'Failed to list terminal sessions');
|
|
|
|
|
const payload: unknown = await response.json().catch(() => null);
|
2026-09-05 03:04:36 -06:00
|
|
|
const rawSessions = terminalSessionListSchema.safeParse(payload).data?.sessions;
|
2026-08-24 14:28:45 +03:00
|
|
|
if (!Array.isArray(rawSessions)) throw new Error('Failed to list terminal sessions');
|
|
|
|
|
const parsed: TerminalServerSession[] = [];
|
2026-09-05 03:04:36 -06:00
|
|
|
for (const entry of rawSessions) {
|
|
|
|
|
const session = terminalServerSessionSchema.safeParse(entry).data;
|
|
|
|
|
if (session) {
|
|
|
|
|
parsed.push(session);
|
|
|
|
|
}
|
2026-08-24 14:28:45 +03:00
|
|
|
}
|
|
|
|
|
return parsed;
|
|
|
|
|
}
|
|
|
|
|
export async function touchTerminalSessions(sessionIds: string[]): Promise<void> {
|
|
|
|
|
if (sessionIds.length === 0) return;
|
|
|
|
|
await command('/api/terminal/touch', 'POST', { sessionIds });
|
|
|
|
|
}
|
2026-07-17 13:17:21 +03:00
|
|
|
export async function listTerminalShells(): Promise<TerminalShellOption[]> {
|
|
|
|
|
const response = await runtimeFetch('/api/terminal/shells');
|
|
|
|
|
if (!response.ok) throw await responseError(response, 'Failed to list terminal shells');
|
|
|
|
|
const payload = await response.json().catch(() => []);
|
|
|
|
|
return Array.isArray(payload)
|
|
|
|
|
? payload.filter((entry): entry is TerminalShellOption => (
|
|
|
|
|
entry && typeof entry === 'object' && isTerminalShell(entry.id) && typeof entry.name === 'string' && typeof entry.supportsLogin === 'boolean'
|
|
|
|
|
))
|
|
|
|
|
: [];
|
2026-02-08 15:39:22 +02:00
|
|
|
}
|
2026-07-17 13:17:21 +03:00
|
|
|
export function connectTerminalStream(sessionId: string, onEvent: TerminalHandlers['onEvent'], onError?: TerminalHandlers['onError']): () => void { return transport.subscribe(sessionId, { onEvent, onError }); }
|
|
|
|
|
export async function sendTerminalInput(sessionId: string, data: string): Promise<void> { await transport.write(sessionId, data); }
|
|
|
|
|
|
|
|
|
|
async function command(path: string, method: string, body?: unknown): Promise<Response> {
|
|
|
|
|
const options: RequestInit = { method };
|
|
|
|
|
if (body !== undefined) {
|
|
|
|
|
options.headers = { 'Content-Type': 'application/json' };
|
|
|
|
|
options.body = JSON.stringify(body);
|
|
|
|
|
}
|
|
|
|
|
const response = await runtimeFetch(path, options);
|
|
|
|
|
if (!response.ok) throw await responseError(response, 'Terminal command failed');
|
|
|
|
|
return response;
|
2026-02-08 15:39:22 +02:00
|
|
|
}
|
2026-09-06 02:14:18 +03:00
|
|
|
export async function resizeTerminal(sessionId: string, cols: number, rows: number): Promise<void> {
|
|
|
|
|
await command(`/api/terminal/${sessionId}/resize`, 'POST', { cols, rows });
|
|
|
|
|
transport.noteResize(sessionId, cols, rows);
|
|
|
|
|
}
|
2026-07-17 13:17:21 +03:00
|
|
|
export async function updateTerminalAppearance(sessionId: string, appearance: Pick<CreateTerminalOptions, 'themeMode' | 'terminalBackground' | 'terminalForeground'>): Promise<void> { await command(`/api/terminal/${sessionId}/appearance`, 'POST', appearance); }
|
|
|
|
|
export async function closeTerminal(sessionId: string): Promise<void> { await command(`/api/terminal/${sessionId}`, 'DELETE'); transport.forget(sessionId); }
|
|
|
|
|
export async function restartTerminalSession(currentSessionId: string, options: CreateTerminalOptions): Promise<TerminalSession> { return (await command(`/api/terminal/${currentSessionId}/restart`, 'POST', options)).json() as Promise<TerminalSession>; }
|
|
|
|
|
export async function forceKillTerminal(options: { sessionId?: string; cwd?: string }): Promise<void> {
|
|
|
|
|
const response = await command('/api/terminal/force-kill', 'POST', options);
|
|
|
|
|
const result = await response.json().catch(() => null) as { killedSessionIds?: unknown } | null;
|
|
|
|
|
if (Array.isArray(result?.killedSessionIds)) {
|
|
|
|
|
for (const sessionId of result.killedSessionIds) if (typeof sessionId === 'string') transport.forget(sessionId);
|
|
|
|
|
} else if (options.sessionId) transport.forget(options.sessionId);
|
2025-12-10 00:25:02 +02:00
|
|
|
}
|
2026-07-17 13:17:21 +03:00
|
|
|
export function disposeTerminalInputTransport(): void { transport.dispose(); transport = new TerminalTransport(); }
|