import {EventEmitter} from 'events'; import {StringDecoder} from 'string_decoder'; import defaultShell from 'default-shell'; import {getDecoratedEnv} from './plugins'; import {productName, version} from './package.json'; import * as config from './config'; import {IPty, IWindowsPtyForkOptions, spawn as npSpawn} from 'node-pty'; const createNodePtyError = () => new Error( '`node-pty` failed to load. Typically this means that it was built incorrectly. Please check the `readme.md` to more info.' ); let spawn: typeof npSpawn; try { spawn = require('node-pty').spawn; } catch (err) { throw createNodePtyError(); } const envFromConfig = config.getConfig().env || {}; const useConpty = config.getConfig().useConpty; // Max duration to batch session data before sending it to the renderer process. const BATCH_DURATION_MS = 16; // Max size of a session data batch. Note that this value can be exceeded by ~4k // (chunk sizes seem to be 4k at the most) const BATCH_MAX_SIZE = 200 * 1024; // Data coming from the pty is sent to the renderer process for further // vt parsing and rendering. This class batches data to minimize the number of // IPC calls. It also reduces GC pressure and CPU cost: each chunk is prefixed // with the window ID which is then stripped on the renderer process and this // overhead is reduced with batching. class DataBatcher extends EventEmitter { uid: string; decoder: StringDecoder; data!: string; timeout!: NodeJS.Timeout | null; constructor(uid: string) { super(); this.uid = uid; this.decoder = new StringDecoder('utf8'); this.reset(); } reset() { this.data = this.uid; this.timeout = null; } write(chunk: Buffer) { if (this.data.length + chunk.length >= BATCH_MAX_SIZE) { // We've reached the max batch size. Flush it and start another one if (this.timeout) { clearTimeout(this.timeout); this.timeout = null; } this.flush(); } this.data += this.decoder.write(chunk); if (!this.timeout) { this.timeout = setTimeout(() => this.flush(), BATCH_DURATION_MS); } } flush() { // Reset before emitting to allow for potential reentrancy const data = this.data; this.reset(); this.emit('flush', data); } } interface SessionOptions { uid: string; rows: number; cols: number; cwd: string; shell: string; shellArgs: string[]; } export default class Session extends EventEmitter { pty: IPty | null; batcher: DataBatcher | null; shell: string | null; ended: boolean; constructor(options: SessionOptions) { super(); this.pty = null; this.batcher = null; this.shell = null; this.ended = false; this.init(options); } init({uid, rows, cols: columns, cwd, shell, shellArgs}: SessionOptions) { const osLocale = require('os-locale') as typeof import('os-locale'); const baseEnv = Object.assign( {}, process.env, { LANG: `${osLocale.sync().replace(/-/, '_')}.UTF-8`, TERM: 'xterm-256color', COLORTERM: 'truecolor', TERM_PROGRAM: productName, TERM_PROGRAM_VERSION: version }, envFromConfig ); // Electron has a default value for process.env.GOOGLE_API_KEY // We don't want to leak this to the shell // See https://github.com/zeit/hyper/issues/696 if (baseEnv.GOOGLE_API_KEY && process.env.GOOGLE_API_KEY === baseEnv.GOOGLE_API_KEY) { delete baseEnv.GOOGLE_API_KEY; } const defaultShellArgs = ['--login']; const options: IWindowsPtyForkOptions = { cols: columns, rows, cwd, env: getDecoratedEnv(baseEnv) }; // if config do not set the useConpty, it will be judged by the node-pty if (typeof useConpty === 'boolean') { options.useConpty = useConpty; } try { this.pty = spawn(shell || defaultShell, shellArgs || defaultShellArgs, options); } catch (err) { if (/is not a function/.test(err.message)) { throw createNodePtyError(); } else { throw err; } } this.batcher = new DataBatcher(uid); this.pty.onData(chunk => { if (this.ended) { return; } this.batcher?.write(chunk as any); }); this.batcher.on('flush', data => { this.emit('data', data); }); this.pty.onExit(() => { if (!this.ended) { this.ended = true; this.emit('exit'); } }); this.shell = shell || defaultShell; } exit() { this.destroy(); } write(data: string) { if (this.pty) { this.pty.write(data); } else { console.warn('Warning: Attempted to write to a session with no pty'); } } resize({cols, rows}: {cols: number; rows: number}) { if (this.pty) { try { this.pty.resize(cols, rows); } catch (err) { console.error(err.stack); } } else { console.warn('Warning: Attempted to resize a session with no pty'); } } destroy() { if (this.pty) { try { this.pty.kill(); } catch (err) { console.error('exit error', err.stack); } } else { console.warn('Warning: Attempted to destroy a session with no pty'); } this.emit('exit'); this.ended = true; } }