UNPKG

@jupyterlite/terminal

Version:
262 lines (231 loc) 8.12 kB
import { PageConfig, URLExt } from '@jupyterlab/coreutils'; import type { Contents, Terminal } from '@jupyterlab/services'; import { ServerConnection } from '@jupyterlab/services'; import type { IExternalCommand, IOutputCallback, IShell, IShellManager, IStdinReply, IStdinRequest } from '@jupyterlite/cockle'; import { ShellManager } from '@jupyterlite/cockle'; import type { JSONPrimitive } from '@lumino/coreutils'; import type { ISignal } from '@lumino/signaling'; import { Signal } from '@lumino/signaling'; import type { Client as WebSocketClient } from 'mock-socket'; import { Server as WebSocketServer } from 'mock-socket'; import type { ITerminalShell } from './shell'; import { TerminalShell } from './shell'; import type { ILiteTerminalAPIClient } from './tokens'; /** * 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 implements ILiteTerminalAPIClient { constructor(options: { serverSettings?: ServerConnection.ISettings } = {}) { this.serverSettings = options.serverSettings ?? ServerConnection.makeSettings(); } /** * Set identifier for communicating with service worker. */ set browsingContextId(browsingContextId: string) { this._browsingContextId = browsingContextId; } /** * Set contents manager used for SharedArrayBuffer DriveFS. */ set contentsManager(contentsManager: Contents.IManager) { this._contentsManager = contentsManager; } /** * Function that handles stdin requests received from service worker. */ async handleStdin(request: IStdinRequest): Promise<IStdinReply> { return await Private.shellManager.handleStdin(request); } get isAvailable(): boolean { const available = String(PageConfig.getOption('terminalsAvailable')); return available.toLowerCase() === 'true'; } readonly serverSettings: ServerConnection.ISettings; async startNew(options?: Terminal.ITerminal.IOptions): Promise<Terminal.IModel> { // 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: ITerminalShell, socket: WebSocketClient): Promise<void> => { shell.socket = socket; socket.on('message', async (message: any) => { // Message from xtermjs to pass to shell. const data = JSON.parse(message) as JSONPrimitive[]; const message_type = data[0]; const content = data.slice(1); await shell.ready; if (message_type === 'stdin') { await shell.input(content[0] as string); } else if (message_type === 'set_size') { const rows = content[0] as number; const columns = content[1] as number; 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: WebSocketClient): void => { hook(shell, socket); }); shell.disposed.connect(() => { this.shutdown(name); wsServer.close(); this._terminalDisposed.emit(shell.shellId); }); return { name }; } async listRunning(): Promise<Terminal.IModel[]> { return this._models; } registerAlias(key: string, value: string): void { if (this._aliases === undefined) { this._aliases = {}; } this._aliases[key] = value; } registerEnvironmentVariable(key: string, value: string | undefined): void { if (this._environment === undefined) { this._environment = {}; } this._environment[key] = value; } registerExternalCommand(options: IExternalCommand.IOptions): void { this._externalCommands.push(options); } async createHeadlessShell(options: { shellId: string; cwd?: string; environment?: { [key: string]: string | undefined }; outputCallback: IOutputCallback; readyTimeoutMs?: number; }): Promise<IShell> { 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: ReturnType<typeof setTimeout> | undefined; try { await Promise.race([ shell.ready, new Promise<never>((_, 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: string): Promise<void> { 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(): ISignal<this, string> { return this._terminalDisposed; } themeChange(isDarkMode?: boolean): void { for (const shell of Private.shells.values()) { shell.themeChange(isDarkMode); } } protected async createShell(options: ITerminalShell.IOptions): Promise<ITerminalShell> { return new TerminalShell(options); } private get _models(): Terminal.IModel[] { return Array.from(Private.shells.keys(), name => { return { name }; }); } private _nextAvailableName(): string { for (let i = 1; ; ++i) { const name = `${i}`; if (!Private.shells.has(name)) { return name; } } } private _aliases?: { [key: string]: string }; private _environment?: { [key: string]: string | undefined }; private _browsingContextId?: string; private _contentsManager?: Contents.IManager; private _externalCommands: IExternalCommand.IOptions[] = []; private _terminalDisposed = new Signal<this, string>(this); } /** * A namespace for private data. */ namespace Private { export const shellManager: IShellManager = new ShellManager(); export const shells = new Map<string, ITerminalShell>(); }