/** * Frames exchanged over a session's MessagePort. * * Terminal bytes travel on a port dedicated to one session rather than the * shared IPC channel. On a shared channel every session's output is serialised * through the same queue, so one session producing a torrent — `cat` of a large * file, `yes` — delays input and output for all the others. A port per session * removes that coupling entirely. */ export type ToMainFrame = /** Keystrokes from the terminal. */ | { type: 'input'; data: Uint8Array } /** The viewport changed size; the remote PTY must be told. */ | { type: 'resize'; cols: number; rows: number } /** The renderer has drawn N bytes and can accept more. */ | { type: 'ack'; bytes: number } /** The port is attached and the buffered banner may be flushed. */ | { type: 'ready' } export type ToRendererFrame = | { type: 'data'; data: Uint8Array } | { type: 'exit'; code: number | null; signal: string | null } | { type: 'error'; message: string } /** The transport dropped on its own. A retry is scheduled. */ | { type: 'reconnecting'; attempt: number; delayMs: number } /** * A new transport is up. The old shell is gone — a fresh one is starting, and * the accumulated scrollback above this line belongs to the previous one. */ | { type: 'reconnected'; attempt: number } /** Retries are exhausted, or the failure is not one a retry can fix. */ | { type: 'disconnected'; message: string } /** * Credit-based flow control. * * Without it, `cat` of a 500MB file queues the whole thing in the renderer and * the UI dies — this is a certainty, not a risk. The remote stream is paused * once this much output is outstanding and resumed when the renderer catches * up. * * The gap between the two marks is deliberate: resuming the instant the * watermark is touched produces a pause/resume oscillation on every chunk. */ export const FLOW_CONTROL = { /** Pause the remote stream above this many unacknowledged bytes. */ highWater: 1 << 20, // 1 MiB /** Resume once outstanding bytes fall back below this. */ lowWater: 1 << 18, // 256 KiB } as const /** * Channel used twice during the port handshake: the main process posts to it, * the preload listens on it, and the preload then reposts to the main world * under the same name. * * The relay is necessary because `contextBridge` cannot carry a `MessagePort` * (electron/electron#27024) — a port passed through it arrives unusable. The * documented way around it is `window.postMessage(..., ports)`, which does * transfer properly. */ /** * Reconnect backoff. * * Starts short so a momentary blip is invisible, and climbs to a ceiling so a * machine that is genuinely gone does not get hammered — or worse, keep a * laptop's radio awake. The jitter matters because this is precisely the case * where several sessions drop at once: without it they would all retry in * lockstep, and the server would see a synchronised stampede each round. */ export const RECONNECT = { initialDelayMs: 1_000, maxDelayMs: 30_000, /** Attempts before giving up and reporting the session as lost. */ maxAttempts: 10, /** Fraction of the delay randomised, either side of the nominal. */ jitter: 0.25, } as const /** The delay before attempt `n` (1-based), with jitter applied. */ export function reconnectDelay(attempt: number): number { const base = Math.min( RECONNECT.maxDelayMs, RECONNECT.initialDelayMs * 2 ** Math.max(0, attempt - 1), ) const spread = base * RECONNECT.jitter return Math.round(base - spread + Math.random() * spread * 2) } export const SESSION_PORT_CHANNEL = 'nexusterm:session-port' as const export interface PortHandshake { sessionId: string }