UNPKG

@jupyterlite/terminal

Version:
207 lines (206 loc) 7.26 kB
import { PageConfig, URLExt } from '@jupyterlab/coreutils'; import { ServerConnection } from '@jupyterlab/services'; import { ShellManager } from '@jupyterlite/cockle'; import { Signal } from '@lumino/signaling'; import { Server as WebSocketServer } from 'mock-socket'; import { TerminalShell } from './shell'; /** * Default time (in milliseconds) to wait for a new shell to become ready. * A shell that fails to start is disposed without its `ready` promise * rejecting, so race the wait against this timeout to surface the failure. */ const DEFAULT_READY_TIMEOUT_MS = 30000; export class LiteTerminalAPIClient { constructor(options = {}) { this.serverSettings = options.serverSettings ?? ServerConnection.makeSettings(); } /** * Set identifier for communicating with service worker. */ set browsingContextId(browsingContextId) { this._browsingContextId = browsingContextId; } /** * Set contents manager used for SharedArrayBuffer DriveFS. */ set contentsManager(contentsManager) { this._contentsManager = contentsManager; } /** * Function that handles stdin requests received from service worker. */ async handleStdin(request) { return await Private.shellManager.handleStdin(request); } get isAvailable() { const available = String(PageConfig.getOption('terminalsAvailable')); return available.toLowerCase() === 'true'; } serverSettings; async startNew(options) { // Create shell. const name = options?.name ?? this._nextAvailableName(); const { baseUrl, wsUrl } = this.serverSettings; const shell = await this.createShell({ mountpoint: '/drive', cwd: options?.cwd, baseUrl, wasmBaseUrl: URLExt.join(baseUrl, 'extensions/@jupyterlite/terminal/static/wasm/'), browsingContextId: this._browsingContextId, contentsManager: this._contentsManager, aliases: this._aliases, environment: this._environment, externalCommands: this._externalCommands, shellId: name, shellManager: Private.shellManager, outputCallback: text => { const msg = JSON.stringify(['stdout', text]); shell.socket?.send(msg); } }); Private.shells.set(name, shell); // Hook to connect socket to shell. const hook = async (shell, socket) => { shell.socket = socket; socket.on('message', async (message) => { // Message from xtermjs to pass to shell. const data = JSON.parse(message); const message_type = data[0]; const content = data.slice(1); await shell.ready; if (message_type === 'stdin') { await shell.input(content[0]); } else if (message_type === 'set_size') { const rows = content[0]; const columns = content[1]; await shell.setSize({ rows, columns }); } }); // Return handshake. const res = JSON.stringify(['setup']); console.log('Terminal returning handshake via socket'); socket.send(res); shell.start(); }; const url = URLExt.join(wsUrl, 'terminals', 'websocket', name); const wsServer = new WebSocketServer(url); wsServer.on('connection', (socket) => { hook(shell, socket); }); shell.disposed.connect(() => { this.shutdown(name); wsServer.close(); this._terminalDisposed.emit(shell.shellId); }); return { name }; } async listRunning() { return this._models; } registerAlias(key, value) { if (this._aliases === undefined) { this._aliases = {}; } this._aliases[key] = value; } registerEnvironmentVariable(key, value) { if (this._environment === undefined) { this._environment = {}; } this._environment[key] = value; } registerExternalCommand(options) { this._externalCommands.push(options); } async createHeadlessShell(options) { const { baseUrl } = this.serverSettings; const environment = options.environment !== undefined ? { ...this._environment, ...options.environment } : this._environment; // `color: true` keeps TERM/TERMINFO populated for commands that need them. const shell = await this.createShell({ shellId: options.shellId, mountpoint: '/drive', cwd: options.cwd, baseUrl, wasmBaseUrl: URLExt.join(baseUrl, 'extensions/@jupyterlite/terminal/static/wasm/'), browsingContextId: this._browsingContextId, contentsManager: this._contentsManager, shellManager: Private.shellManager, aliases: this._aliases, environment, externalCommands: this._externalCommands, color: true, outputCallback: options.outputCallback }); const readyTimeoutMs = options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS; let readyTimer; try { await Promise.race([ shell.ready, new Promise((_, reject) => { readyTimer = setTimeout(() => reject(new Error(`Timed out after ${readyTimeoutMs}ms waiting for cockle shell '${options.shellId}' to become ready`)), readyTimeoutMs); }) ]); await shell.start(); } catch (err) { shell.dispose(); throw err; } finally { if (readyTimer !== undefined) { clearTimeout(readyTimer); } } return shell; } async shutdown(name) { const shell = Private.shells.get(name); if (shell !== undefined) { shell.socket?.send(JSON.stringify(['disconnect'])); shell.socket?.close(); Private.shells.delete(name); shell.dispose(); } } get terminalDisposed() { return this._terminalDisposed; } themeChange(isDarkMode) { for (const shell of Private.shells.values()) { shell.themeChange(isDarkMode); } } async createShell(options) { return new TerminalShell(options); } get _models() { return Array.from(Private.shells.keys(), name => { return { name }; }); } _nextAvailableName() { for (let i = 1;; ++i) { const name = `${i}`; if (!Private.shells.has(name)) { return name; } } } _aliases; _environment; _browsingContextId; _contentsManager; _externalCommands = []; _terminalDisposed = new Signal(this); } /** * A namespace for private data. */ var Private; (function (Private) { Private.shellManager = new ShellManager(); Private.shells = new Map(); })(Private || (Private = {}));