271 lines
7.6 KiB
TypeScript
271 lines
7.6 KiB
TypeScript
export const protocols = ["webtty"];
|
|
|
|
export const msgInputUnknown = '0';
|
|
export const msgInput = '1';
|
|
export const msgPing = '2';
|
|
export const msgResizeTerminal = '3';
|
|
export const msgSetEncoding = '4';
|
|
|
|
export const msgUnknownOutput = '0';
|
|
export const msgOutput = '1';
|
|
export const msgPong = '2';
|
|
export const msgSetWindowTitle = '3';
|
|
export const msgSetPreferences = '4';
|
|
export const msgSetReconnect = '5';
|
|
export const msgSetBufferSize = '6';
|
|
|
|
|
|
export interface Terminal {
|
|
/*
|
|
* Get dimensions of the terminal
|
|
*/
|
|
info(): { columns: number, rows: number };
|
|
|
|
/*
|
|
* Process output from the server side
|
|
*/
|
|
output(data: Uint8Array): void;
|
|
|
|
/*
|
|
* Display a message overlay on the terminal
|
|
*/
|
|
showMessage(message: string, timeout: number): void;
|
|
|
|
// Don't think we need this anymore
|
|
// getMessage(): HTMLElement;
|
|
|
|
/*
|
|
* Remove message shown by shoMessage. You only need to call
|
|
* this if you want to dismiss it sooner than the timeout.
|
|
*/
|
|
removeMessage(): void;
|
|
|
|
|
|
/*
|
|
* Set window title
|
|
*/
|
|
setWindowTitle(title: string): void;
|
|
|
|
/*
|
|
* Set preferences. TODO: Add typings
|
|
*/
|
|
setPreferences(value: object): void;
|
|
|
|
|
|
/*
|
|
* Sets an input (e.g. user types something) handler
|
|
*/
|
|
onInput(callback: (input: string) => void): void;
|
|
|
|
/*
|
|
* Sets a resize handler
|
|
*/
|
|
onResize(callback: (colmuns: number, rows: number) => void): void;
|
|
|
|
reset(): void;
|
|
deactivate(): void;
|
|
close(): void;
|
|
}
|
|
|
|
export interface Connection {
|
|
open(): void;
|
|
close(): void;
|
|
|
|
/*
|
|
* This takes fucking strings??
|
|
*/
|
|
send(s: string): void;
|
|
|
|
isOpen(): boolean;
|
|
onOpen(callback: () => void): void;
|
|
onReceive(callback: (data: string) => void): void;
|
|
onClose(callback: () => void): void;
|
|
}
|
|
|
|
export interface ConnectionFactory {
|
|
create(): Connection;
|
|
}
|
|
|
|
export class WebTTY {
|
|
/*
|
|
* A terminal instance that implements the Terminal interface.
|
|
* This made a lot of sense when we had both HTerm and xterm, but
|
|
* now I wonder if the abstraction makes sense. Keeping it for now,
|
|
* though.
|
|
*/
|
|
term: Terminal;
|
|
|
|
/*
|
|
* ConnectionFactory and connection instance. We pass the factory
|
|
* in instead of just a connection so that we can reconnect.
|
|
*/
|
|
connectionFactory: ConnectionFactory;
|
|
connection: Connection;
|
|
|
|
/*
|
|
* Arguments passed in by the user. We forward them to the backend
|
|
* where they are appended to the command line.
|
|
*/
|
|
args: string;
|
|
|
|
/*
|
|
* An authentication token. The client gets this from `/auth_token.js`.
|
|
*/
|
|
authToken: string;
|
|
|
|
/*
|
|
* If connection is dropped, reconnect after `reconnect` seconds.
|
|
* -1 means do not reconnect.
|
|
*/
|
|
reconnect: number;
|
|
|
|
/*
|
|
* The server's buffer size. If a single message exceeds this size, it will
|
|
* be truncated on the server, so we track it here so that we can split messages
|
|
* into chunks small enough that we don't hurt the server's feelings.
|
|
*/
|
|
bufSize: number;
|
|
|
|
constructor(term: Terminal, connectionFactory: ConnectionFactory, args: string, authToken: string) {
|
|
this.term = term;
|
|
this.connectionFactory = connectionFactory;
|
|
this.args = args;
|
|
this.authToken = authToken;
|
|
this.reconnect = -1;
|
|
this.bufSize = 1024;
|
|
};
|
|
|
|
open() {
|
|
let connection = this.connectionFactory.create();
|
|
let pingTimer: NodeJS.Timeout;
|
|
let reconnectTimeout: NodeJS.Timeout;
|
|
this.connection = connection;
|
|
|
|
const setup = () => {
|
|
connection.onOpen(() => {
|
|
const termInfo = this.term.info();
|
|
|
|
this.initializeConnection(this.args, this.authToken);
|
|
|
|
this.term.onResize((columns: number, rows: number) => {
|
|
this.sendResizeTerminal(columns, rows);
|
|
});
|
|
|
|
this.sendResizeTerminal(termInfo.columns, termInfo.rows);
|
|
|
|
this.sendSetEncoding("base64");
|
|
|
|
this.term.onInput(
|
|
(input: string | Uint8Array) => {
|
|
this.sendInput(input);
|
|
}
|
|
);
|
|
|
|
pingTimer = setInterval(() => {
|
|
this.sendPing()
|
|
}, 30 * 1000);
|
|
});
|
|
|
|
connection.onReceive((data) => {
|
|
const payload = data.slice(1);
|
|
switch (data[0]) {
|
|
case msgOutput:
|
|
this.term.output(Uint8Array.from(atob(payload), c => c.charCodeAt(0)));
|
|
break;
|
|
case msgPong:
|
|
break;
|
|
case msgSetWindowTitle:
|
|
this.term.setWindowTitle(payload);
|
|
break;
|
|
case msgSetPreferences:
|
|
const preferences = JSON.parse(payload);
|
|
this.term.setPreferences(preferences);
|
|
break;
|
|
case msgSetReconnect:
|
|
const autoReconnect = JSON.parse(payload);
|
|
console.log("Enabling reconnect: " + autoReconnect + " seconds")
|
|
this.reconnect = autoReconnect;
|
|
break;
|
|
case msgSetBufferSize:
|
|
const bufSize = JSON.parse(payload);
|
|
this.bufSize = bufSize;
|
|
break;
|
|
}
|
|
});
|
|
|
|
connection.onClose(() => {
|
|
clearInterval(pingTimer);
|
|
this.term.deactivate();
|
|
this.term.showMessage("Connection Closed", 0);
|
|
if (this.reconnect > 0) {
|
|
reconnectTimeout = setTimeout(() => {
|
|
connection = this.connectionFactory.create();
|
|
this.term.reset();
|
|
setup();
|
|
}, this.reconnect * 1000);
|
|
}
|
|
});
|
|
|
|
connection.open();
|
|
}
|
|
|
|
setup();
|
|
return () => {
|
|
clearTimeout(reconnectTimeout);
|
|
connection.close();
|
|
}
|
|
};
|
|
|
|
private initializeConnection(args, authToken) {
|
|
this.connection.send(JSON.stringify(
|
|
{
|
|
Arguments: args,
|
|
AuthToken: authToken,
|
|
}
|
|
));
|
|
}
|
|
|
|
/*
|
|
* sendInput sends data to the server. It accepts strings or Uint8Arrays.
|
|
* strings will be encoded as UTF-8. Uint8Arrays are passed along as-is.
|
|
*/
|
|
private sendInput(input: string | Uint8Array) {
|
|
let effectiveBufferSize = this.bufSize - 1;
|
|
let dataString: string;
|
|
|
|
if (typeof input === "string") {
|
|
dataString = input;
|
|
} else {
|
|
dataString = String.fromCharCode(...input)
|
|
}
|
|
|
|
// Account for base64 encoding
|
|
let maxChunkSize = Math.floor(effectiveBufferSize / 4) * 3;
|
|
|
|
for (let i = 0; i < Math.ceil(dataString.length / maxChunkSize); i++) {
|
|
let inputChunk = dataString.substring(i * maxChunkSize, Math.min((i + 1) * maxChunkSize, dataString.length))
|
|
this.connection.send(msgInput + btoa(inputChunk));
|
|
}
|
|
}
|
|
|
|
private sendPing(): void {
|
|
this.connection.send(msgPing);
|
|
}
|
|
|
|
private sendResizeTerminal(colmuns: number, rows: number) {
|
|
this.connection.send(
|
|
msgResizeTerminal + JSON.stringify(
|
|
{
|
|
columns: colmuns,
|
|
rows: rows
|
|
}
|
|
)
|
|
);
|
|
}
|
|
|
|
private sendSetEncoding(encoding: "base64" | "null") {
|
|
this.connection.send(msgSetEncoding + encoding)
|
|
}
|
|
|
|
};
|