From b6ee9e01cedda62d8afa742420782c7ae517d38b Mon Sep 17 00:00:00 2001 From: Ian Thomas Date: Fri, 17 May 2024 11:35:33 +0100 Subject: [PATCH] Basic framework --- .gitignore | 3 +++ jupyter-lite.json | 6 +++++ package.json | 12 +++++++-- src/index.ts | 67 +++++++++++++++++++++++++++++++++++++++++------ src/terminal.ts | 54 ++++++++++++++++++++++++++++++++++++++ src/terminals.ts | 60 ++++++++++++++++++++++++++++++++++++++++++ src/tokens.ts | 53 +++++++++++++++++++++++++++++++++++++ 7 files changed, 245 insertions(+), 10 deletions(-) create mode 100644 jupyter-lite.json create mode 100644 src/terminal.ts create mode 100644 src/terminals.ts create mode 100644 src/tokens.ts diff --git a/.gitignore b/.gitignore index 3ec3e4b..171da36 100644 --- a/.gitignore +++ b/.gitignore @@ -123,3 +123,6 @@ dmypy.json # Yarn cache .yarn/ + +.jupyterlite.doit.db +_output/ diff --git a/jupyter-lite.json b/jupyter-lite.json new file mode 100644 index 0000000..31aaf07 --- /dev/null +++ b/jupyter-lite.json @@ -0,0 +1,6 @@ +{ + "jupyter-lite-schema-version": 0, + "jupyter-config-data": { + "terminalsAvailable": true + } +} diff --git a/package.json b/package.json index 719ba91..02eaa74 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "keywords": [ "jupyter", "jupyterlab", - "jupyterlab-extension" + "jupyterlite", + "jupyterlite-extension" ], "homepage": "https://github.com/ianthomas23/jupyterlite-terminal", "bugs": { @@ -56,7 +57,11 @@ "watch:labextension": "jupyter labextension watch ." }, "dependencies": { - "@jupyterlab/application": "^4.0.0" + "@jupyterlab/services": "^7.2.0", + "@jupyterlab/terminal": "^4.2.0", + "@jupyterlab/terminal-extension": "^4.2.0", + "@jupyterlite/server": "^0.3.0", + "@lumino/coreutils": "^2.1.2" }, "devDependencies": { "@jupyterlab/builder": "^4.0.0", @@ -97,6 +102,9 @@ "extension": true, "outputDir": "jupyterlite_terminal/labextension" }, + "jupyterlite": { + "liteExtension": true + }, "eslintIgnore": [ "node_modules", "dist", diff --git a/src/index.ts b/src/index.ts index ce1cdbd..0a9eebe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,18 +1,69 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + import { - JupyterFrontEnd, - JupyterFrontEndPlugin -} from '@jupyterlab/application'; + JupyterLiteServer, + JupyterLiteServerPlugin, + Router, +} from '@jupyterlite/server'; +import { ITerminalTracker } from '@jupyterlab/terminal'; + +import { ITerminals } from './tokens'; +import { Terminals } from './terminals'; + /** - * Initialization data for the jupyterlite-terminal extension. + * The terminals service plugin. */ -const plugin: JupyterFrontEndPlugin = { +const terminalsPlugin: JupyterLiteServerPlugin = { id: 'jupyterlite-terminal:plugin', description: 'A terminal for JupyterLite', autoStart: true, - activate: (app: JupyterFrontEnd) => { - console.log('JupyterLab extension jupyterlite-terminal is activated!'); + requires: [ITerminalTracker], + provides: ITerminals, + activate: async (app: JupyterLiteServer, tracker: ITerminalTracker) => { + console.log('JupyterLab extension jupyterlite-terminal:plugin is activated!'); + + console.log("==> ITerminalTracker", tracker); + + const { serviceManager } = app; + const { contents, serverSettings, terminals } = serviceManager; + console.log("terminals available:", terminals.isAvailable()); + console.log("terminals ready:", terminals.isReady); // Not ready + console.log("terminals active:", terminals.isActive); + + // Not sure this is necessary? + await terminals.ready; + console.log("terminals ready after await:", terminals.isReady); // Ready + + return new Terminals(serverSettings.wsUrl, contents); } }; -export default plugin; +/** + * A plugin providing the routes for the terminals service + */ +const terminalsRoutesPlugin: JupyterLiteServerPlugin = { + id: 'jupyterlite-terminal:routes-plugin', + autoStart: true, + requires: [ITerminals], + activate: (app: JupyterLiteServer, terminals: ITerminals) => { + console.log('JupyterLab extension jupyterlite-terminal:routes-plugin is activated!', terminals); + + // GET /api/terminals - List the running terminals + app.router.get('/api/terminals', async (req: Router.IRequest) => { + const res = terminals.list(); + // Should return last_activity for each too, + return new Response(JSON.stringify(res)); + }); + + // POST /api/terminals - Start a terminal + app.router.post('/api/terminals', async (req: Router.IRequest) => { + const res = await terminals.startNew(); + // Should return last_activity too. + return new Response(JSON.stringify(res)); + }); + }, +}; + +export default [terminalsPlugin, terminalsRoutesPlugin]; diff --git a/src/terminal.ts b/src/terminal.ts new file mode 100644 index 0000000..ac34b30 --- /dev/null +++ b/src/terminal.ts @@ -0,0 +1,54 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { JSONPrimitive } from '@lumino/coreutils'; + +import { Server as WebSocketServer, Client as WebSocketClient } from 'mock-socket'; + +import { ITerminal } from './tokens'; + +export class Terminal implements ITerminal { + /** + * Construct a new Terminal. + */ + constructor(options: ITerminal.IOptions) { + this._name = options.name; + } + + /** + * Get the name of the terminal. + */ + get name(): string { + return this._name; + } + + async wsConnect(url: string) { + console.log("==> Terminal.wsConnect", url); + + const server = new WebSocketServer(url, { mock: false }); + + server.on('connection', async (socket: WebSocketClient) => { + console.log("==> server connection", this, socket); + + socket.on('message', async (message: any) => { + const data = JSON.parse(message) as JSONPrimitive[]; + console.log("==> socket message", data); + }); + + socket.on('close', async () => { + console.log("==> socket close"); + }); + + socket.on('error', async () => { + console.log("==> socket error"); + }); + + // Return handshake. + const res = JSON.stringify(['setup']); + console.log("==> Returning handshake via socket", res); + socket.send(res); + }); + } + + private _name: string; +} diff --git a/src/terminals.ts b/src/terminals.ts new file mode 100644 index 0000000..2b6b438 --- /dev/null +++ b/src/terminals.ts @@ -0,0 +1,60 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { Contents, TerminalAPI } from '@jupyterlab/services'; + +import { Terminal } from './terminal'; +import { ITerminals } from './tokens'; + +/** + * A class to handle requests to /api/terminals + */ +export class Terminals implements ITerminals { + /** + * Construct a new Terminals object. + */ + constructor(wsUrl: string, contentsManager: Contents.IManager) { + this._wsUrl = wsUrl; + this._contentsManager = contentsManager; + console.log("==> Terminals.constructor", this._wsUrl, this._contentsManager); + } + + /** + * List the running terminals. + */ + async list(): Promise { + const ret = [...this._terminals.values()].map((terminal) => ({ + name: terminal.name, + })); + console.log("==> Terminals.list", ret); + return ret; + } + + /** + * Start a new kernel. + */ + async startNew(): Promise { + const name = this._nextAvailableName(); + console.log("==> Terminals.new", name); + const term = new Terminal({ name, contentsManager: this._contentsManager }); + this._terminals.set(name, term); + + const url = `${this._wsUrl}terminals/websocket/${name}`; + await term.wsConnect(url); + + return { name }; + } + + private _nextAvailableName(): string { + for (let i = 1; ; ++i) { + const name = `${i}`; + if (!this._terminals.has(name)) { + return name; + } + } + } + + private _wsUrl: string; + private _contentsManager: Contents.IManager; + private _terminals: Map = new Map(); +} diff --git a/src/tokens.ts b/src/tokens.ts new file mode 100644 index 0000000..d0e3f2a --- /dev/null +++ b/src/tokens.ts @@ -0,0 +1,53 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { Contents, TerminalAPI } from '@jupyterlab/services'; + +import { Token } from '@lumino/coreutils'; + +/** + * The token for the Terminals service. + */ +export const ITerminals = new Token('@jupyterlite/terminal:ITerminals'); + +/** + * An interface for the Terminals service. + */ +export interface ITerminals { + /** + * List the running terminals. + */ + list: () => Promise; + + /** + * Start a new kernel. + */ + startNew: () => Promise; +} + +/** + * An interface for a server-side terminal running in the browser. + */ +export interface ITerminal { + /** + * The name of the server-side terminal. + */ + readonly name: string; +} + +/** + * A namespace for ITerminal statics. + */ +export namespace ITerminal { + /** + * The instantiation options for an ITerminal. + */ + export interface IOptions { + /** + * The name of the terminal. + */ + name: string; + + contentsManager: Contents.IManager; + } +}