@jupyterlite/terminal
Version:
A terminal for JupyterLite
207 lines (206 loc) • 7.26 kB
JavaScript
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 = {}));