mirror of
https://github.com/lensapp/lens.git
synced 2025-05-20 05:10:56 +00:00
Fix handling of PTYs when renderer is reloaded (#4346)
Co-authored-by: Jari Kolehmainen <jari.kolehmainen@gmail.com>
This commit is contained in:
parent
e146dff9da
commit
0f2801552f
@ -29,6 +29,7 @@ import { get } from "lodash";
|
|||||||
import { Node, NodesApi } from "../../common/k8s-api/endpoints";
|
import { Node, NodesApi } from "../../common/k8s-api/endpoints";
|
||||||
import { KubeJsonApi } from "../../common/k8s-api/kube-json-api";
|
import { KubeJsonApi } from "../../common/k8s-api/kube-json-api";
|
||||||
import logger from "../logger";
|
import logger from "../logger";
|
||||||
|
import { TerminalChannels } from "../../renderer/api/terminal-api";
|
||||||
|
|
||||||
export class NodeShellSession extends ShellSession {
|
export class NodeShellSession extends ShellSession {
|
||||||
ShellType = "node-shell";
|
ShellType = "node-shell";
|
||||||
@ -51,7 +52,10 @@ export class NodeShellSession extends ShellSession {
|
|||||||
await this.waitForRunningPod();
|
await this.waitForRunningPod();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.deleteNodeShellPod();
|
this.deleteNodeShellPod();
|
||||||
this.sendResponse(`Error occurred: ${get(error, "response.body.message", error?.toString() || "unknown error")}`);
|
this.send({
|
||||||
|
type: TerminalChannels.STDOUT,
|
||||||
|
data: `Error occurred: ${get(error, "response.body.message", error?.toString() || "unknown error")}`,
|
||||||
|
});
|
||||||
|
|
||||||
throw new ShellOpenError("failed to create node pod", error);
|
throw new ShellOpenError("failed to create node pod", error);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -32,6 +32,8 @@ import { UserStore } from "../../common/user-store";
|
|||||||
import * as pty from "node-pty";
|
import * as pty from "node-pty";
|
||||||
import { appEventBus } from "../../common/event-bus";
|
import { appEventBus } from "../../common/event-bus";
|
||||||
import logger from "../logger";
|
import logger from "../logger";
|
||||||
|
import { TerminalChannels, TerminalMessage } from "../../renderer/api/terminal-api";
|
||||||
|
import { deserialize, serialize } from "v8";
|
||||||
|
|
||||||
export class ShellOpenError extends Error {
|
export class ShellOpenError extends Error {
|
||||||
constructor(message: string, public cause: Error) {
|
constructor(message: string, public cause: Error) {
|
||||||
@ -145,18 +147,24 @@ export abstract class ShellSession {
|
|||||||
|
|
||||||
protected abstract get cwd(): string | undefined;
|
protected abstract get cwd(): string | undefined;
|
||||||
|
|
||||||
protected ensureShellProcess(shell: string, args: string[], env: Record<string, string>, cwd: string): pty.IPty {
|
protected ensureShellProcess(shell: string, args: string[], env: Record<string, string>, cwd: string): { shellProcess: pty.IPty, resume: boolean } {
|
||||||
if (!ShellSession.processes.has(this.terminalId)) {
|
const resume = ShellSession.processes.has(this.terminalId);
|
||||||
|
|
||||||
|
if (!resume) {
|
||||||
ShellSession.processes.set(this.terminalId, pty.spawn(shell, args, {
|
ShellSession.processes.set(this.terminalId, pty.spawn(shell, args, {
|
||||||
|
rows: 30,
|
||||||
cols: 80,
|
cols: 80,
|
||||||
cwd,
|
cwd,
|
||||||
env,
|
env,
|
||||||
name: "xterm-256color",
|
name: "xterm-256color",
|
||||||
rows: 30,
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
return ShellSession.processes.get(this.terminalId);
|
const shellProcess = ShellSession.processes.get(this.terminalId);
|
||||||
|
|
||||||
|
logger.info(`[SHELL-SESSION]: PTY for ${this.terminalId} is ${resume ? "resumed" : "started"} with PID=${shellProcess.pid}`);
|
||||||
|
|
||||||
|
return { shellProcess, resume };
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(protected websocket: WebSocket, protected cluster: Cluster, terminalId: string) {
|
constructor(protected websocket: WebSocket, protected cluster: Cluster, terminalId: string) {
|
||||||
@ -166,56 +174,88 @@ export abstract class ShellSession {
|
|||||||
this.terminalId = `${cluster.id}:${terminalId}`;
|
this.terminalId = `${cluster.id}:${terminalId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected send(message: TerminalMessage): void {
|
||||||
|
this.websocket.send(serialize(message));
|
||||||
|
}
|
||||||
|
|
||||||
protected async openShellProcess(shell: string, args: string[], env: Record<string, any>) {
|
protected async openShellProcess(shell: string, args: string[], env: Record<string, any>) {
|
||||||
const cwd = (this.cwd && await fse.pathExists(this.cwd))
|
const cwd = (this.cwd && await fse.pathExists(this.cwd))
|
||||||
? this.cwd
|
? this.cwd
|
||||||
: env.HOME;
|
: env.HOME;
|
||||||
const shellProcess = this.ensureShellProcess(shell, args, env, cwd);
|
const { shellProcess, resume } = this.ensureShellProcess(shell, args, env, cwd);
|
||||||
|
|
||||||
|
if (resume) {
|
||||||
|
this.send({ type: TerminalChannels.CONNECTED });
|
||||||
|
}
|
||||||
|
|
||||||
this.running = true;
|
this.running = true;
|
||||||
shellProcess.onData(data => this.sendResponse(data));
|
shellProcess.onData(data => this.send({ type: TerminalChannels.STDOUT, data }));
|
||||||
shellProcess.onExit(({ exitCode }) => {
|
shellProcess.onExit(({ exitCode }) => {
|
||||||
this.running = false;
|
logger.info(`[SHELL-SESSION]: shell has exited for ${this.terminalId} closed with exitcode=${exitCode}`);
|
||||||
|
|
||||||
if (exitCode > 0) {
|
// This might already be false because of the kill() within the websocket.on("close") handler
|
||||||
this.sendResponse("Terminal will auto-close in 15 seconds ...");
|
if (this.running) {
|
||||||
setTimeout(() => this.exit(), 15 * 1000);
|
this.running = false;
|
||||||
} else {
|
|
||||||
this.exit();
|
if (exitCode > 0) {
|
||||||
|
this.send({ type: TerminalChannels.STDOUT, data: "Terminal will auto-close in 15 seconds ..." });
|
||||||
|
setTimeout(() => this.exit(), 15 * 1000);
|
||||||
|
} else {
|
||||||
|
this.exit();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
this.websocket
|
this.websocket
|
||||||
.on("message", (data: string) => {
|
.on("message", (data: string | Uint8Array) => {
|
||||||
if (!this.running) {
|
if (!this.running) {
|
||||||
return;
|
return void logger.debug(`[SHELL-SESSION]: received message from ${this.terminalId}, but shellProcess isn't running`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = Buffer.from(data.slice(1, data.length), "base64").toString();
|
if (typeof data === "string") {
|
||||||
|
return void logger.silly(`[SHELL-SESSION]: Received message from ${this.terminalId}`, { data });
|
||||||
|
}
|
||||||
|
|
||||||
switch (data[0]) {
|
try {
|
||||||
case "0":
|
const message: TerminalMessage = deserialize(data);
|
||||||
shellProcess.write(message);
|
|
||||||
break;
|
|
||||||
case "4":
|
|
||||||
const { Width, Height } = JSON.parse(message);
|
|
||||||
|
|
||||||
shellProcess.resize(Width, Height);
|
switch (message.type) {
|
||||||
break;
|
case TerminalChannels.STDIN:
|
||||||
|
shellProcess.write(message.data);
|
||||||
|
break;
|
||||||
|
case TerminalChannels.RESIZE:
|
||||||
|
shellProcess.resize(message.data.width, message.data.height);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
logger.warn(`[SHELL-SESSION]: unknown or unhandleable message type for ${this.terminalId}`, message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[SHELL-SESSION]: failed to handle message for ${this.terminalId}`, error);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.on("close", (code) => {
|
.on("close", code => {
|
||||||
logger.debug(`[SHELL-SESSION]: websocket for ${this.terminalId} closed with code=${code}`);
|
logger.info(`[SHELL-SESSION]: websocket for ${this.terminalId} closed with code=${WebSocketCloseEvent[code]}(${code})`, { cluster: this.cluster.getMeta() });
|
||||||
|
|
||||||
if (this.running && code !== WebSocketCloseEvent.AbnormalClosure) {
|
const stopShellSession = this.running
|
||||||
// This code is the one that gets sent when the network is turned off
|
&& (
|
||||||
try {
|
(
|
||||||
logger.info(`[SHELL-SESSION]: Killing shell process for ${this.terminalId}`);
|
code !== WebSocketCloseEvent.AbnormalClosure
|
||||||
process.kill(shellProcess.pid);
|
&& code !== WebSocketCloseEvent.GoingAway
|
||||||
ShellSession.processes.delete(this.terminalId);
|
)
|
||||||
} catch (e) {
|
|| this.cluster.disconnected
|
||||||
}
|
);
|
||||||
|
|
||||||
|
if (stopShellSession) {
|
||||||
this.running = false;
|
this.running = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
logger.info(`[SHELL-SESSION]: Killing shell process (pid=${shellProcess.pid}) for ${this.terminalId}`);
|
||||||
|
shellProcess.kill();
|
||||||
|
ShellSession.processes.delete(this.terminalId);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`[SHELL-SESSION]: failed to kill shell process (pid=${shellProcess.pid}) for ${this.terminalId}`, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -300,8 +340,4 @@ export abstract class ShellSession {
|
|||||||
this.websocket.close(code);
|
this.websocket.close(code);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected sendResponse(msg: string) {
|
|
||||||
this.websocket.send(`1${Buffer.from(msg).toString("base64")}`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -19,22 +19,39 @@
|
|||||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { boundMethod, base64, EventEmitter, getHostedClusterId } from "../utils";
|
import { getHostedClusterId } from "../utils";
|
||||||
import { WebSocketApi } from "./websocket-api";
|
import { WebSocketApi, WebSocketEvents } from "./websocket-api";
|
||||||
import isEqual from "lodash/isEqual";
|
import isEqual from "lodash/isEqual";
|
||||||
import { isDevelopment } from "../../common/vars";
|
|
||||||
import url from "url";
|
import url from "url";
|
||||||
import { makeObservable, observable } from "mobx";
|
import { makeObservable, observable } from "mobx";
|
||||||
import { ipcRenderer } from "electron";
|
import { ipcRenderer } from "electron";
|
||||||
|
import logger from "../../common/logger";
|
||||||
|
import { deserialize, serialize } from "v8";
|
||||||
|
import { once } from "lodash";
|
||||||
|
|
||||||
export enum TerminalChannels {
|
export enum TerminalChannels {
|
||||||
STDIN = 0,
|
STDIN = "stdin",
|
||||||
STDOUT = 1,
|
STDOUT = "stdout",
|
||||||
STDERR = 2,
|
CONNECTED = "connected",
|
||||||
TERMINAL_SIZE = 4,
|
RESIZE = "resize",
|
||||||
TOKEN = 9,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TerminalMessage = {
|
||||||
|
type: TerminalChannels.STDIN,
|
||||||
|
data: string,
|
||||||
|
} | {
|
||||||
|
type: TerminalChannels.STDOUT,
|
||||||
|
data: string,
|
||||||
|
} | {
|
||||||
|
type: TerminalChannels.CONNECTED
|
||||||
|
} | {
|
||||||
|
type: TerminalChannels.RESIZE,
|
||||||
|
data: {
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
enum TerminalColor {
|
enum TerminalColor {
|
||||||
RED = "\u001b[31m",
|
RED = "\u001b[31m",
|
||||||
GREEN = "\u001b[32m",
|
GREEN = "\u001b[32m",
|
||||||
@ -53,17 +70,20 @@ export type TerminalApiQuery = Record<string, string> & {
|
|||||||
type?: string;
|
type?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export class TerminalApi extends WebSocketApi {
|
export interface TerminalEvents extends WebSocketEvents {
|
||||||
protected size: { Width: number; Height: number };
|
ready: () => void;
|
||||||
|
connected: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TerminalApi extends WebSocketApi<TerminalEvents> {
|
||||||
|
protected size: { width: number; height: number };
|
||||||
|
|
||||||
public onReady = new EventEmitter<[]>();
|
|
||||||
@observable public isReady = false;
|
@observable public isReady = false;
|
||||||
|
|
||||||
constructor(protected query: TerminalApiQuery) {
|
constructor(protected query: TerminalApiQuery) {
|
||||||
super({
|
super({
|
||||||
logging: isDevelopment,
|
|
||||||
flushOnOpen: false,
|
flushOnOpen: false,
|
||||||
pingIntervalSeconds: 30,
|
pingInterval: 30,
|
||||||
});
|
});
|
||||||
makeObservable(this);
|
makeObservable(this);
|
||||||
|
|
||||||
@ -100,56 +120,81 @@ export class TerminalApi extends WebSocketApi {
|
|||||||
slashes: true,
|
slashes: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.onData.addListener(this._onReady, { prepend: true });
|
const onReady = once((data?: string) => {
|
||||||
|
this.isReady = true;
|
||||||
|
this.emit("ready");
|
||||||
|
this.removeListener("data", onReady);
|
||||||
|
this.removeListener("connected", onReady);
|
||||||
|
this.flush();
|
||||||
|
|
||||||
|
// data is undefined if the event that was handled is "connected"
|
||||||
|
if (data === undefined) {
|
||||||
|
/**
|
||||||
|
* Output the last line, the makes sure that the terminal isn't completely
|
||||||
|
* empty when the user refreshes.
|
||||||
|
*/
|
||||||
|
this.emit("data", window.localStorage.getItem(`${this.query.id}:last-data`));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.prependListener("data", onReady);
|
||||||
|
this.prependListener("connected", onReady);
|
||||||
|
|
||||||
super.connect(socketUrl);
|
super.connect(socketUrl);
|
||||||
|
this.socket.binaryType = "arraybuffer";
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
if (!this.socket) return;
|
if (!this.socket) return;
|
||||||
const exitCode = String.fromCharCode(4); // ctrl+d
|
const controlCode = String.fromCharCode(4); // ctrl+d
|
||||||
|
|
||||||
this.sendCommand(exitCode);
|
this.sendMessage({ type: TerminalChannels.STDIN, data: controlCode });
|
||||||
setTimeout(() => super.destroy(), 2000);
|
setTimeout(() => super.destroy(), 2000);
|
||||||
}
|
}
|
||||||
|
|
||||||
removeAllListeners() {
|
|
||||||
super.removeAllListeners();
|
|
||||||
this.onReady.removeAllListeners();
|
|
||||||
}
|
|
||||||
|
|
||||||
@boundMethod
|
|
||||||
protected _onReady(data: string) {
|
|
||||||
if (!data) return true;
|
|
||||||
this.isReady = true;
|
|
||||||
this.onReady.emit();
|
|
||||||
this.onData.removeListener(this._onReady);
|
|
||||||
this.flush();
|
|
||||||
this.onData.emit(data); // re-emit data
|
|
||||||
|
|
||||||
return false; // prevent calling rest of listeners
|
|
||||||
}
|
|
||||||
|
|
||||||
reconnect() {
|
reconnect() {
|
||||||
super.reconnect();
|
super.reconnect();
|
||||||
}
|
}
|
||||||
|
|
||||||
sendCommand(key: string, channel = TerminalChannels.STDIN) {
|
sendMessage(message: TerminalMessage) {
|
||||||
return this.send(channel + base64.encode(key));
|
return this.send(serialize(message));
|
||||||
}
|
}
|
||||||
|
|
||||||
sendTerminalSize(cols: number, rows: number) {
|
sendTerminalSize(cols: number, rows: number) {
|
||||||
const newSize = { Width: cols, Height: rows };
|
const newSize = { width: cols, height: rows };
|
||||||
|
|
||||||
if (!isEqual(this.size, newSize)) {
|
if (!isEqual(this.size, newSize)) {
|
||||||
this.sendCommand(JSON.stringify(newSize), TerminalChannels.TERMINAL_SIZE);
|
this.sendMessage({
|
||||||
|
type: TerminalChannels.RESIZE,
|
||||||
|
data: newSize,
|
||||||
|
});
|
||||||
this.size = newSize;
|
this.size = newSize;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected parseMessage(data: string) {
|
protected _onMessage({ data, ...evt }: MessageEvent<ArrayBuffer>): void {
|
||||||
data = data.substr(1); // skip channel
|
try {
|
||||||
|
const message: TerminalMessage = deserialize(new Uint8Array(data));
|
||||||
|
|
||||||
return base64.decode(data);
|
switch (message.type) {
|
||||||
|
case TerminalChannels.STDOUT:
|
||||||
|
/**
|
||||||
|
* save the last data for reconnections. User localStorage because we
|
||||||
|
* don't want this data to survive if the app is closed
|
||||||
|
*/
|
||||||
|
window.localStorage.setItem(`${this.query.id}:last-data`, message.data);
|
||||||
|
super._onMessage({ data: message.data, ...evt });
|
||||||
|
break;
|
||||||
|
case TerminalChannels.CONNECTED:
|
||||||
|
this.emit("connected");
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
logger.warn(`[TERMINAL-API]: unknown or unhandleable message type`, message);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`[TERMINAL-API]: failed to handle message`, error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _onOpen(evt: Event) {
|
protected _onOpen(evt: Event) {
|
||||||
@ -166,16 +211,13 @@ export class TerminalApi extends WebSocketApi {
|
|||||||
|
|
||||||
protected emitStatus(data: string, options: { color?: TerminalColor; showTime?: boolean } = {}) {
|
protected emitStatus(data: string, options: { color?: TerminalColor; showTime?: boolean } = {}) {
|
||||||
const { color, showTime } = options;
|
const { color, showTime } = options;
|
||||||
|
const time = showTime ? `${(new Date()).toLocaleString()} ` : "";
|
||||||
|
|
||||||
if (color) {
|
if (color) {
|
||||||
data = `${color}${data}${TerminalColor.NO_COLOR}`;
|
data = `${color}${data}${TerminalColor.NO_COLOR}`;
|
||||||
}
|
}
|
||||||
let time;
|
|
||||||
|
|
||||||
if (showTime) {
|
this.emit("data", `${time}${data}\r\n`);
|
||||||
time = `${(new Date()).toLocaleString()} `;
|
|
||||||
}
|
|
||||||
this.onData.emit(`${showTime ? time : ""}${data}\r\n`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
protected emitError(error: string) {
|
protected emitError(error: string) {
|
||||||
|
|||||||
@ -20,20 +20,48 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { observable, makeObservable } from "mobx";
|
import { observable, makeObservable } from "mobx";
|
||||||
import { EventEmitter } from "../../common/event-emitter";
|
import EventEmitter from "events";
|
||||||
|
import type TypedEventEmitter from "typed-emitter";
|
||||||
|
import type { Arguments } from "typed-emitter";
|
||||||
|
import { isDevelopment } from "../../common/vars";
|
||||||
|
|
||||||
interface IParams {
|
interface WebsocketApiParams {
|
||||||
url?: string; // connection url, starts with ws:// or wss://
|
/**
|
||||||
autoConnect?: boolean; // auto-connect in constructor
|
* Flush pending commands on open socket
|
||||||
flushOnOpen?: boolean; // flush pending commands on open socket
|
*
|
||||||
reconnectDelaySeconds?: number; // reconnect timeout in case of error (0 - don't reconnect)
|
* @default true
|
||||||
pingIntervalSeconds?: number; // send ping message for keeping connection alive in some env, e.g. AWS (0 - don't ping)
|
*/
|
||||||
logging?: boolean; // show logs in console
|
flushOnOpen?: boolean;
|
||||||
}
|
|
||||||
|
|
||||||
interface IMessage {
|
/**
|
||||||
id: string;
|
* In case of an error, wait this many seconds before reconnecting.
|
||||||
data: string;
|
*
|
||||||
|
* If falsy, don't reconnect
|
||||||
|
*
|
||||||
|
* @default 10
|
||||||
|
*/
|
||||||
|
reconnectDelay?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The message for pinging the websocket
|
||||||
|
*
|
||||||
|
* @default "PING"
|
||||||
|
*/
|
||||||
|
pingMessage?: string | ArrayBufferLike | Blob | ArrayBufferView;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If set to a number > 0, then the API will ping the socket on that interval.
|
||||||
|
*
|
||||||
|
* @unit seconds
|
||||||
|
*/
|
||||||
|
pingInterval?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether to show logs in the console
|
||||||
|
*
|
||||||
|
* @default isDevelopment
|
||||||
|
*/
|
||||||
|
logging?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum WebSocketApiState {
|
export enum WebSocketApiState {
|
||||||
@ -44,79 +72,74 @@ export enum WebSocketApiState {
|
|||||||
CLOSED = "closed",
|
CLOSED = "closed",
|
||||||
}
|
}
|
||||||
|
|
||||||
export class WebSocketApi {
|
export interface WebSocketEvents {
|
||||||
|
open: () => void,
|
||||||
|
data: (message: string) => void;
|
||||||
|
close: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Defaulted<Params, DefaultParams extends keyof Params> = Required<Pick<Params, DefaultParams>> & Omit<Params, DefaultParams>;
|
||||||
|
|
||||||
|
export class WebSocketApi<Events extends WebSocketEvents> extends (EventEmitter as { new<T>(): TypedEventEmitter<T> })<Events> {
|
||||||
protected socket: WebSocket;
|
protected socket: WebSocket;
|
||||||
protected pendingCommands: IMessage[] = [];
|
protected pendingCommands: (string | ArrayBufferLike | Blob | ArrayBufferView)[] = [];
|
||||||
protected reconnectTimer: any;
|
protected reconnectTimer?: any;
|
||||||
protected pingTimer: any;
|
protected pingTimer?: any;
|
||||||
protected pingMessage = "PING";
|
protected params: Defaulted<WebsocketApiParams, keyof typeof WebSocketApi["defaultParams"]>;
|
||||||
|
|
||||||
@observable readyState = WebSocketApiState.PENDING;
|
@observable readyState = WebSocketApiState.PENDING;
|
||||||
|
|
||||||
public onOpen = new EventEmitter<[]>();
|
private static defaultParams = {
|
||||||
public onData = new EventEmitter<[string]>();
|
logging: isDevelopment,
|
||||||
public onClose = new EventEmitter<[]>();
|
reconnectDelay: 10,
|
||||||
|
|
||||||
static defaultParams: Partial<IParams> = {
|
|
||||||
autoConnect: true,
|
|
||||||
logging: false,
|
|
||||||
reconnectDelaySeconds: 10,
|
|
||||||
pingIntervalSeconds: 0,
|
|
||||||
flushOnOpen: true,
|
flushOnOpen: true,
|
||||||
|
pingMessage: "PING",
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(protected params: IParams) {
|
constructor(params: WebsocketApiParams) {
|
||||||
|
super();
|
||||||
makeObservable(this);
|
makeObservable(this);
|
||||||
this.params = Object.assign({}, WebSocketApi.defaultParams, params);
|
this.params = Object.assign({}, WebSocketApi.defaultParams, params);
|
||||||
const { autoConnect, pingIntervalSeconds } = this.params;
|
const { pingInterval } = this.params;
|
||||||
|
|
||||||
if (autoConnect) {
|
if (pingInterval) {
|
||||||
setTimeout(() => this.connect());
|
this.pingTimer = setInterval(() => this.ping(), pingInterval * 1000);
|
||||||
}
|
|
||||||
|
|
||||||
if (pingIntervalSeconds) {
|
|
||||||
this.pingTimer = setInterval(() => this.ping(), pingIntervalSeconds * 1000);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get isConnected() {
|
get isConnected() {
|
||||||
const state = this.socket ? this.socket.readyState : -1;
|
return this.socket?.readyState === WebSocket.OPEN && this.isOnline;
|
||||||
|
|
||||||
return state === WebSocket.OPEN && this.isOnline;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get isOnline() {
|
get isOnline() {
|
||||||
return navigator.onLine;
|
return navigator.onLine;
|
||||||
}
|
}
|
||||||
|
|
||||||
setParams(params: Partial<IParams>) {
|
connect(url: string) {
|
||||||
Object.assign(this.params, params);
|
// close previous connection first
|
||||||
}
|
this.socket?.close();
|
||||||
|
|
||||||
connect(url = this.params.url) {
|
// start new connection
|
||||||
if (this.socket) {
|
|
||||||
this.socket.close(); // close previous connection first
|
|
||||||
}
|
|
||||||
this.socket = new WebSocket(url);
|
this.socket = new WebSocket(url);
|
||||||
this.socket.onopen = this._onOpen.bind(this);
|
this.socket.addEventListener("open", ev => this._onOpen(ev));
|
||||||
this.socket.onmessage = this._onMessage.bind(this);
|
this.socket.addEventListener("message", ev => this._onMessage(ev));
|
||||||
this.socket.onerror = this._onError.bind(this);
|
this.socket.addEventListener("error", ev => this._onError(ev));
|
||||||
this.socket.onclose = this._onClose.bind(this);
|
this.socket.addEventListener("close", ev => this._onClose(ev));
|
||||||
this.readyState = WebSocketApiState.CONNECTING;
|
this.readyState = WebSocketApiState.CONNECTING;
|
||||||
}
|
}
|
||||||
|
|
||||||
ping() {
|
ping() {
|
||||||
if (!this.isConnected) return;
|
if (this.isConnected) {
|
||||||
this.send(this.pingMessage);
|
this.send(this.params.pingMessage);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
reconnect() {
|
reconnect(): void {
|
||||||
const { reconnectDelaySeconds } = this.params;
|
if (!this.socket) {
|
||||||
|
return void console.error("[WEBSOCKET-API]: cannot reconnect to a socket that is not connected");
|
||||||
|
}
|
||||||
|
|
||||||
if (!reconnectDelaySeconds) return;
|
this.connect(this.socket.url);
|
||||||
this.writeLog("reconnect after", `${reconnectDelaySeconds}ms`);
|
|
||||||
this.reconnectTimer = setTimeout(() => this.connect(), reconnectDelaySeconds * 1000);
|
|
||||||
this.readyState = WebSocketApiState.RECONNECTING;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
@ -124,52 +147,43 @@ export class WebSocketApi {
|
|||||||
this.socket.close();
|
this.socket.close();
|
||||||
this.socket = null;
|
this.socket = null;
|
||||||
this.pendingCommands = [];
|
this.pendingCommands = [];
|
||||||
this.removeAllListeners();
|
this.clearAllListeners();
|
||||||
clearTimeout(this.reconnectTimer);
|
clearTimeout(this.reconnectTimer);
|
||||||
clearInterval(this.pingTimer);
|
clearInterval(this.pingTimer);
|
||||||
this.readyState = WebSocketApiState.PENDING;
|
this.readyState = WebSocketApiState.PENDING;
|
||||||
}
|
}
|
||||||
|
|
||||||
removeAllListeners() {
|
clearAllListeners() {
|
||||||
this.onOpen.removeAllListeners();
|
for (const name of this.eventNames()) {
|
||||||
this.onData.removeAllListeners();
|
this.removeAllListeners(name as keyof Events);
|
||||||
this.onClose.removeAllListeners();
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
send(command: string) {
|
send(command: string | ArrayBufferLike | Blob | ArrayBufferView) {
|
||||||
const msg: IMessage = {
|
|
||||||
id: (Math.random() * Date.now()).toString(16).replace(".", ""),
|
|
||||||
data: command,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (this.isConnected) {
|
if (this.isConnected) {
|
||||||
this.socket.send(msg.data);
|
this.socket.send(command);
|
||||||
}
|
} else {
|
||||||
else {
|
this.pendingCommands.push(command);
|
||||||
this.pendingCommands.push(msg);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
protected flush() {
|
protected flush() {
|
||||||
this.pendingCommands.forEach(msg => this.send(msg.data));
|
for (const command of this.pendingCommands) {
|
||||||
|
this.send(command);
|
||||||
|
}
|
||||||
|
|
||||||
this.pendingCommands.length = 0;
|
this.pendingCommands.length = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected parseMessage(data: string) {
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
protected _onOpen(evt: Event) {
|
protected _onOpen(evt: Event) {
|
||||||
this.onOpen.emit();
|
this.emit("open", ...[] as Arguments<Events["open"]>);
|
||||||
if (this.params.flushOnOpen) this.flush();
|
if (this.params.flushOnOpen) this.flush();
|
||||||
this.readyState = WebSocketApiState.OPEN;
|
this.readyState = WebSocketApiState.OPEN;
|
||||||
this.writeLog("%cOPEN", "color:green;font-weight:bold;", evt);
|
this.writeLog("%cOPEN", "color:green;font-weight:bold;", evt);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _onMessage(evt: MessageEvent) {
|
protected _onMessage({ data }: MessageEvent): void {
|
||||||
const data = this.parseMessage(evt.data);
|
this.emit("data", ...[data] as Arguments<Events["data"]>);
|
||||||
|
|
||||||
this.onData.emit(data);
|
|
||||||
this.writeLog("%cMESSAGE", "color:black;font-weight:bold;", data);
|
this.writeLog("%cMESSAGE", "color:black;font-weight:bold;", data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -181,18 +195,26 @@ export class WebSocketApi {
|
|||||||
const error = evt.code !== 1000 || !evt.wasClean;
|
const error = evt.code !== 1000 || !evt.wasClean;
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
this.reconnect();
|
const { reconnectDelay } = this.params;
|
||||||
}
|
|
||||||
else {
|
if (reconnectDelay) {
|
||||||
|
const url = this.socket.url;
|
||||||
|
|
||||||
|
this.writeLog("will reconnect in", `${reconnectDelay}s`);
|
||||||
|
|
||||||
|
this.reconnectTimer = setTimeout(() => this.connect(url), reconnectDelay * 1000);
|
||||||
|
this.readyState = WebSocketApiState.RECONNECTING;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
this.readyState = WebSocketApiState.CLOSED;
|
this.readyState = WebSocketApiState.CLOSED;
|
||||||
this.onClose.emit();
|
this.emit("close", ...[] as Arguments<Events["close"]>);
|
||||||
}
|
}
|
||||||
this.writeLog("%cCLOSE", `color:${error ? "red" : "black"};font-weight:bold;`, evt);
|
this.writeLog("%cCLOSE", `color:${error ? "red" : "black"};font-weight:bold;`, evt);
|
||||||
}
|
}
|
||||||
|
|
||||||
protected writeLog(...data: any[]) {
|
protected writeLog(...data: any[]) {
|
||||||
if (this.params.logging) {
|
if (this.params.logging) {
|
||||||
console.log(...data);
|
console.debug(...data);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,7 +22,7 @@
|
|||||||
import { autorun, observable, when } from "mobx";
|
import { autorun, observable, when } from "mobx";
|
||||||
import { autoBind, noop, Singleton } from "../../utils";
|
import { autoBind, noop, Singleton } from "../../utils";
|
||||||
import { Terminal } from "./terminal";
|
import { Terminal } from "./terminal";
|
||||||
import { TerminalApi } from "../../api/terminal-api";
|
import { TerminalApi, TerminalChannels } from "../../api/terminal-api";
|
||||||
import { dockStore, DockTab, DockTabCreateSpecific, TabId, TabKind } from "./dock.store";
|
import { dockStore, DockTab, DockTabCreateSpecific, TabId, TabKind } from "./dock.store";
|
||||||
import { WebSocketApiState } from "../../api/websocket-api";
|
import { WebSocketApiState } from "../../api/websocket-api";
|
||||||
import { Notifications } from "../notifications";
|
import { Notifications } from "../notifications";
|
||||||
@ -78,6 +78,8 @@ export class TerminalStore extends Singleton {
|
|||||||
|
|
||||||
this.connections.set(tabId, api);
|
this.connections.set(tabId, api);
|
||||||
this.terminals.set(tabId, terminal);
|
this.terminals.set(tabId, terminal);
|
||||||
|
|
||||||
|
api.connect();
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnect(tabId: TabId) {
|
disconnect(tabId: TabId) {
|
||||||
@ -135,7 +137,14 @@ export class TerminalStore extends Singleton {
|
|||||||
const terminalApi = this.connections.get(dockStore.selectedTabId);
|
const terminalApi = this.connections.get(dockStore.selectedTabId);
|
||||||
|
|
||||||
if (terminalApi) {
|
if (terminalApi) {
|
||||||
terminalApi.sendCommand(command + (enter ? "\r" : ""));
|
if (enter) {
|
||||||
|
command += "\r";
|
||||||
|
}
|
||||||
|
|
||||||
|
terminalApi.sendMessage({
|
||||||
|
type: TerminalChannels.STDIN,
|
||||||
|
data: command,
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
console.warn("The selected tab is does not have a connection. Cannot send command.", { tabId: dockStore.selectedTabId, command });
|
console.warn("The selected tab is does not have a connection. Cannot send command.", { tabId: dockStore.selectedTabId, command });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -24,11 +24,11 @@ import { reaction } from "mobx";
|
|||||||
import { Terminal as XTerm } from "xterm";
|
import { Terminal as XTerm } from "xterm";
|
||||||
import { FitAddon } from "xterm-addon-fit";
|
import { FitAddon } from "xterm-addon-fit";
|
||||||
import { dockStore, TabId } from "./dock.store";
|
import { dockStore, TabId } from "./dock.store";
|
||||||
import type { TerminalApi } from "../../api/terminal-api";
|
import { TerminalApi, TerminalChannels } from "../../api/terminal-api";
|
||||||
import { ThemeStore } from "../../theme.store";
|
import { ThemeStore } from "../../theme.store";
|
||||||
import { boundMethod, disposer } from "../../utils";
|
import { boundMethod, disposer } from "../../utils";
|
||||||
import { isMac } from "../../../common/vars";
|
import { isMac } from "../../../common/vars";
|
||||||
import { camelCase } from "lodash";
|
import { camelCase, once } from "lodash";
|
||||||
import { UserStore } from "../../../common/user-store";
|
import { UserStore } from "../../../common/user-store";
|
||||||
import { clipboard } from "electron";
|
import { clipboard } from "electron";
|
||||||
import logger from "../../../common/logger";
|
import logger from "../../../common/logger";
|
||||||
@ -119,11 +119,13 @@ export class Terminal {
|
|||||||
|
|
||||||
// bind events
|
// bind events
|
||||||
const onDataHandler = this.xterm.onData(this.onData);
|
const onDataHandler = this.xterm.onData(this.onData);
|
||||||
|
const clearOnce = once(this.onClear);
|
||||||
|
|
||||||
this.viewport.addEventListener("scroll", this.onScroll);
|
this.viewport.addEventListener("scroll", this.onScroll);
|
||||||
this.elem.addEventListener("contextmenu", this.onContextMenu);
|
this.elem.addEventListener("contextmenu", this.onContextMenu);
|
||||||
this.api.onReady.addListener(this.onClear, { once: true }); // clear status logs (connecting..)
|
this.api.once("ready", clearOnce);
|
||||||
this.api.onData.addListener(this.onApiData);
|
this.api.once("connected", clearOnce);
|
||||||
|
this.api.on("data", this.onApiData);
|
||||||
window.addEventListener("resize", this.onResize);
|
window.addEventListener("resize", this.onResize);
|
||||||
|
|
||||||
this.disposer.push(
|
this.disposer.push(
|
||||||
@ -176,7 +178,10 @@ export class Terminal {
|
|||||||
|
|
||||||
onData = (data: string) => {
|
onData = (data: string) => {
|
||||||
if (!this.api.isReady) return;
|
if (!this.api.isReady) return;
|
||||||
this.api.sendCommand(data);
|
this.api.sendMessage({
|
||||||
|
type: TerminalChannels.STDIN,
|
||||||
|
data,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
onScroll = () => {
|
onScroll = () => {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user