UNPKG

@lvce-editor/pty-host

Version:

Terminal process used in Lvce Editor.

531 lines (499 loc) 12.1 kB
import { register, execute } from '@lvce-editor/command'; import { IpcChildWithWebSocket, IpcChildWithElectronMessagePort, IpcChildWithElectronUtilityProcess, IpcChildWithNodeForkedProcess } from '@lvce-editor/ipc'; import * as JsonRpc from '@lvce-editor/json-rpc'; import { handleJsonRpcMessage, resolve } from '@lvce-editor/json-rpc'; import debugModule from 'debug'; import { spawn } from 'node-pty'; import { VError } from '@lvce-editor/verror'; import { Console } from 'node:console'; import { createWriteStream } from 'node:fs'; import { tmpdir } from 'node:os'; import { prepare } from '@lvce-editor/pretty-error'; const createRpc = ipc => { const rpc = { // @ts-ignore ipc, /** * @deprecated */ send(method, ...params) { JsonRpc.send(ipc, method, ...params); }, invoke(method, ...params) { return JsonRpc.invoke(ipc, method, ...params); }, invokeAndTransfer(method, ...params) { return JsonRpc.invokeAndTransfer(ipc, method, ...params); }, async dispose() { await ipc?.dispose(); } }; return rpc; }; const requiresSocket$1 = () => { return false; }; const preparePrettyError = error => { return error; }; const logError = () => { // handled by renderer worker }; const handleMessage = event => { const actualRequiresSocket = event?.target?.requiresSocket || requiresSocket$1; const actualExecute = event?.target?.execute || execute; return handleJsonRpcMessage(event.target, event.data, actualExecute, resolve, preparePrettyError, logError, actualRequiresSocket); }; const handleIpc = ipc => { if ('addEventListener' in ipc) { ipc.addEventListener('message', handleMessage); } else if ('on' in ipc) { // deprecated ipc.on('message', handleMessage); } }; const listen$2 = async (module, options) => { const rawIpc = await module.listen(options); if (module.signal) { module.signal(rawIpc); } const ipc = module.wrap(rawIpc); return ipc; }; const create$e = async ({ commandMap, messagePort, requiresSocket }) => { // TODO create a commandMap per rpc instance register(commandMap); const ipc = await listen$2(IpcChildWithElectronMessagePort, { messagePort }); if (requiresSocket) { ipc.requiresSocket = requiresSocket; } handleIpc(ipc); const rpc = createRpc(ipc); return rpc; }; const ElectronMessagePortRpcClient = { __proto__: null, create: create$e }; const create$d = async ({ commandMap }) => { // TODO create a commandMap per rpc instance register(commandMap); const ipc = await listen$2(IpcChildWithElectronUtilityProcess); handleIpc(ipc); const rpc = createRpc(ipc); return rpc; }; const ElectronUtilityProcessRpcClient = { __proto__: null, create: create$d }; const create$7 = async ({ commandMap }) => { // TODO create a commandMap per rpc instance register(commandMap); const ipc = await listen$2(IpcChildWithNodeForkedProcess); handleIpc(ipc); const rpc = createRpc(ipc); return rpc; }; const NodeForkedProcessRpcClient = { __proto__: null, create: create$7 }; const create$5 = async ({ commandMap, request, handle, requiresSocket }) => { // TODO create a commandMap per rpc instance register(commandMap); const ipc = await listen$2(IpcChildWithWebSocket, { request, handle }); if (requiresSocket) { ipc.requiresSocket = requiresSocket; } handleIpc(ipc); const rpc = createRpc(ipc); return rpc; }; const NodeWebSocketRpcClient = { __proto__: null, create: create$5 }; class AssertionError extends Error { constructor(message) { super(message); this.name = 'AssertionError'; } } const getType = value => { switch (typeof value) { case 'number': return 'number'; case 'function': return 'function'; case 'string': return 'string'; case 'object': if (value === null) { return 'null'; } if (Array.isArray(value)) { return 'array'; } return 'object'; case 'boolean': return 'boolean'; default: return 'unknown'; } }; const object = value => { const type = getType(value); if (type !== 'object') { throw new AssertionError('expected value to be of type object'); } }; const number = value => { const type = getType(value); if (type !== 'number') { throw new AssertionError('expected value to be of type number'); } }; const array = value => { const type = getType(value); if (type !== 'array') { throw new AssertionError('expected value to be of type array'); } }; const string = value => { const type = getType(value); if (type !== 'string') { throw new AssertionError('expected value to be of type string'); } }; const METHODS_THAT_REQUIRE_SOCKET = new Set(['Terminal.create']); const requiresSocket = method => { return METHODS_THAT_REQUIRE_SOCKET.has(method); }; const handleElectronMessagePort = async messagePort => { object(messagePort); await ElectronMessagePortRpcClient.create({ commandMap: {}, messagePort, requiresSocket: requiresSocket }); }; const NodeWorker = 1; const NodeForkedProcess = 2; const WebSocket = 3; const ElectronUtilityProcess = 4; const ElectronMessagePort = 5; const NodeMessagePort = 6; const Auto = () => { const { argv } = process; if (argv.includes('--ipc-type=node-worker')) { return NodeWorker; } if (argv.includes('--ipc-type=node-forked-process')) { return NodeForkedProcess; } if (argv.includes('--ipc-type=electron-utility-process')) { return ElectronUtilityProcess; } throw new Error(`[pty-host] unknown ipc type`); }; const getModule = method => { switch (method) { case NodeForkedProcess: return NodeForkedProcessRpcClient; case WebSocket: return NodeWebSocketRpcClient; case ElectronUtilityProcess: return ElectronUtilityProcessRpcClient; case ElectronMessagePort: return ElectronMessagePortRpcClient; default: throw new Error('unexpected ipc type'); } }; const listen$1 = async ({ method, ...params }) => { const module = getModule(method); // @ts-ignore const rpc = await module.create(params); return rpc; }; const handleNodeMessagePort = async messagePort => { await listen$1({ method: NodeMessagePort, messagePort }); }; const handleWebSocket = async (handle, request) => { object(handle); object(request); await NodeWebSocketRpcClient.create({ commandMap: {}, handle, request, requiresSocket: requiresSocket }); }; const debug = debugModule('pty-host'); class DataEvent extends Event { constructor(data) { super('data'); this.data = data; } } class ExitEvent extends Event { constructor(data) { super('exit'); this.data = data; } } class Pty extends EventTarget { /** * * @param {import('node-pty').IPty} pty */ constructor(pty) { super(); this.pty = pty; const handleData = data => { this.dispatchEvent(new DataEvent(data)); }; const handleExit = data => { this.dispatchEvent(new ExitEvent(data)); }; this.pty.onData(handleData); this.pty.onExit(handleExit); } resize(columns, rows) { this.pty.resize(columns, rows); } dispose() { this.pty.kill(); } write(data) { this.pty.write(data); } } /** * * @param {*} param0 * @returns {any} */ const create$1 = ({ cwd, command, args } = {}) => { try { string(cwd); string(command); array(args); const pty = spawn(command, args, { encoding: null, cwd // cols: 10, // rows: 10, }); const wrapped = new Pty(pty); return wrapped; } catch (error) { throw new VError(error, `Failed to create terminal`); } }; const state$1 = { ptyMap: Object.create(null) }; const set = (id, pty) => { state$1.ptyMap[id] = pty; }; const get = id => { return state$1.ptyMap[id]; }; const remove = id => { delete state$1.ptyMap[id]; }; // TODO maybe merge pty and pty controller const create = (ipc, id, cwd, command, args) => { number(id); string(cwd); string(command); array(args); object(ipc); debug(`create ${id} ${cwd}`); // @ts-ignore const pty = create$1({ cwd, command, args }); const handleData = event => { ipc.send({ jsonrpc: '2.0', method: 'Viewlet.send', params: [id, 'handleData', event.data] }); }; pty.addEventListener('data', handleData); set(id, pty); }; const write = (id, data) => { const pty = get(id); if (!pty) { throw new Error(`pty ${id} not found`); } pty.write(data); }; const resize = (id, columns, rows) => { const pty = get(id); if (!pty) { throw new Error(`pty ${id} not found`); } pty.resize(columns, rows); }; const dispose = id => { const pty = get(id); if (!pty) { throw new Error(`pty ${id} not found`); } pty.dispose(); remove(id); }; const HandleElectronMessagePort = 'HandleElectronMessagePort.handleElectronMessagePort'; const HandleNodeMessagePort = 'HandleNodeMessagePort.handleNodeMessagePort'; const HandleWebSocket = 'HandleWebSocket.handleWebSocket'; const TerminalCreate = 'Terminal.create'; const TerminalResize = 'Terminal.resize'; const TerminalWrite = 'Terminal.write'; const TerminalDispose = 'Terminal.dispose'; const commandMap = { [HandleElectronMessagePort]: handleElectronMessagePort, [HandleNodeMessagePort]: handleNodeMessagePort, [HandleWebSocket]: handleWebSocket, [TerminalCreate]: create, [TerminalResize]: resize, [TerminalWrite]: write, [TerminalDispose]: dispose }; const listen = async () => { await listen$1({ method: Auto() }); }; const NewLine = '\n'; const Error$1 = 1; const Success = 0; /** * * @param {string} string * @param {number|undefined} startIndex * @returns */ const getNewLineIndex = (string, startIndex = undefined) => { return string.indexOf(NewLine, startIndex); }; const EPIPE = 'EPIPE'; const ERR_IPC_CHANNEL_CLOSED = 'ERR_IPC_CHANNEL_CLOSED'; const exit = code => { process.exit(code); }; const setExitCode = exitCode => { process.exitCode = exitCode; }; const isConnected = () => { return process.connected; }; const isIgnoredError = error => { if (error && error.code === EPIPE && !process.connected) { // parent process is disposed, ignore return true; } if (error && error.code === ERR_IPC_CHANNEL_CLOSED && !isConnected()) { // parent process is disposed, ignore return true; } return false; }; // TODO mock this module when used in unit tests const state = { console: undefined }; const createConsole = () => { const logFile = `${tmpdir()}/log-terminal-process.txt`; const writeStream = createWriteStream(logFile); const logger = new Console(writeStream); return logger; }; const getOrCreateLogger = () => { if (!state.console) { state.console = createConsole(); } return state.console; }; const info = (...args) => { const logger = getOrCreateLogger(); logger.info(...args); console.info(...args); }; const error = (...args) => { const logger = getOrCreateLogger(); logger.error(...args); console.error(...args); }; const firstErrorLine = error => { if (error.stack) { return error.stack.slice(0, getNewLineIndex(error.stack)); } if (error.message) { return error.message; } return `${error}`; }; const handleUncaughtExceptionMonitor$1 = error$1 => { info(`[pty host] uncaught exception: ${firstErrorLine(error$1)}`); if (isIgnoredError(error$1)) { return; } const prettyError = prepare(error$1); error( // @ts-ignore prettyError.codeFrame + NewLine + prettyError.stack + NewLine); setExitCode(Error$1); }; const handleDisconnect = () => { console.info('[pty host] disconnected'); exit(Success); }; const handleUncaughtExceptionMonitor = error => { // @ts-ignore handleUncaughtExceptionMonitor$1(error); }; const main = async () => { process.on('uncaughtExceptionMonitor', handleUncaughtExceptionMonitor); process.on('disconnect', handleDisconnect); register(commandMap); await listen(); }; main();