Connection tree with interleaved folders and hosts, tabbed terminals grouped by host, a pinned local shell, TOFU host key verification, and credentials held by the OS keyring. Terminal bytes travel on a per-session MessagePort with credit-based flow control, so one session running `cat` on a large file cannot stall the others. Transports reconnect on their own with jittered backoff, keeping the scrollback and the tab while replacing only the connection beneath. 156 tests, including integration tests against a real sshd and a migration exercised against a database built in the old shape.
95 lines
3.7 KiB
TypeScript
95 lines
3.7 KiB
TypeScript
/**
|
|
* 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
|
|
}
|