@jupyterlite/terminal
Version:
A terminal for JupyterLite
262 lines (231 loc) • 8.12 kB
text/typescript
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>();
}