diff --git a/src/app/plugins/notebook/commands.ts b/src/app/plugins/notebook/commands.ts index 9f2c7ce..41daa84 100644 --- a/src/app/plugins/notebook/commands.ts +++ b/src/app/plugins/notebook/commands.ts @@ -96,10 +96,18 @@ export const addCommands = ( }); commands.addCommand(CommandIDs.new, { - label: 'New Notebook', - execute: async () => { + label: (args: any) => { + const kernelName = args['kernelName']; + if (args['isLauncher'] && kernelName) { + return kernelName; + } + return 'New Notebook'; + }, + execute: (args: any) => { + const kernelName = args['kernelName'] as string; commands.execute(CommandIDs.open, { - name: 'untitled.ipynb' + name: 'untitled.ipynb', + kernelName }); } }); @@ -108,13 +116,16 @@ export const addCommands = ( label: (args: any) => { const name = args['name'] as string; if (name) { - return `Open ${name}`; + return `${name}`; } return 'Open Example'; }, execute: async args => { const name = args['name'] as string; - const notebook = docManager.open(name) as NotebookPanel; + const kernelName = args['kernelName'] as string; + const notebook = docManager.open(name, 'default', { + name: kernelName + }) as NotebookPanel; const sessionContext = notebook.sessionContext; await sessionContext.ready; diff --git a/src/resources/pyp5js.svg b/src/resources/pyp5js.svg new file mode 100644 index 0000000..752fc82 --- /dev/null +++ b/src/resources/pyp5js.svg @@ -0,0 +1,93 @@ + +image/svg+xml + + + diff --git a/src/server/contents.ts b/src/server/contents.ts index 4fcc017..5067fb8 100644 --- a/src/server/contents.ts +++ b/src/server/contents.ts @@ -116,23 +116,7 @@ namespace Private { */ const EMPTY_NB: INotebookContent = { metadata: { - orig_nbformat: 4, - kernelspec: { - name: 'p5.js', - display_name: 'p5.js' - }, - language_info: { - codemirror_mode: { - name: 'javascript', - version: 3 - }, - file_extension: '.js', - mimetype: 'text/javascript', - name: 'javascript', - nbconvert_exporter: 'javascript', - pygments_lexer: 'javascript', - version: 'es2017' - } + orig_nbformat: 4 }, nbformat_minor: 4, nbformat: 4, @@ -149,7 +133,7 @@ namespace Private { created: '2020-03-18T18:41:01.243007Z', content: EMPTY_NB, format: 'json', - mimetype: '', + mimetype: 'application/json', size: JSON.stringify(EMPTY_NB).length, writable: true, type: 'notebook' diff --git a/src/server/kernels.ts b/src/server/kernels.ts index 66f7e24..dc09e2e 100644 --- a/src/server/kernels.ts +++ b/src/server/kernels.ts @@ -9,9 +9,10 @@ import { import { Server as WebSocketServer } from 'mock-socket'; -import { KernelIFrame } from './kernel'; +import { KernelIFrame } from './kernels/kernel'; import { IJupyterServer } from '../tokens'; +import { PyP5KernelIFrame } from './kernels/pyp5Kernel'; /** * A class to handle requests to /api/kernels @@ -20,10 +21,10 @@ export class Kernels implements IJupyterServer.IRoutable { /** * Start a new kernel. * - * @param sessionId The session id. + * @param options The kernel start options. */ - startNew(sessionId: string): Kernel.IModel { - const id = sessionId; + startNew(options: Kernels.IStartOptions): Kernel.IModel { + const { id, name } = options; const kernelUrl = `${Kernels.WS_BASE_URL}/api/kernels/${id}/channels`; const wsServer = new WebSocketServer(kernelUrl); @@ -34,7 +35,14 @@ export class Kernels implements IJupyterServer.IRoutable { socket.send(message); }; - const kernel = new KernelIFrame({ id, sendMessage, sessionId }); + let kernel: IJupyterServer.IKernelIFrame; + + // TODO: more generic kernel instantiation + if (name === 'pyp5') { + kernel = new PyP5KernelIFrame({ id, sendMessage, sessionId: id }); + } else { + kernel = new KernelIFrame({ id, sendMessage, sessionId: id }); + } this._kernels.set(id, kernel); socket.on('message', (message: string | ArrayBuffer) => { @@ -53,7 +61,7 @@ export class Kernels implements IJupyterServer.IRoutable { const model = { id, - name: 'p5.js' + name: name ?? 'p5.js' }; return model; } @@ -81,13 +89,28 @@ export class Kernels implements IJupyterServer.IRoutable { return new Response(null); } - private _kernels = new ObservableMap(); + private _kernels = new ObservableMap(); } /** * A namespace for Kernels statics. */ export namespace Kernels { + /** + * Options to start a new options. + */ + export interface IStartOptions { + /** + * The kernel id. + */ + id: string; + + /** + * The kernel name. + */ + name?: string; + } + /** * The base url for the Kernels manager */ diff --git a/src/server/kernel.ts b/src/server/kernels/kernel.ts similarity index 99% rename from src/server/kernel.ts rename to src/server/kernels/kernel.ts index 984c897..864f4c5 100644 --- a/src/server/kernel.ts +++ b/src/server/kernels/kernel.ts @@ -8,7 +8,7 @@ import p5 from '!!raw-loader!p5/lib/p5.min.js'; import p5Sound from '!!raw-loader!p5/lib/addons/p5.sound.min.js'; -import { IJupyterServer } from '../tokens'; +import { IJupyterServer } from '../../tokens'; /** * A kernel that executes code in an IFrame. diff --git a/src/server/kernels/pyp5Kernel.ts b/src/server/kernels/pyp5Kernel.ts new file mode 100644 index 0000000..09239fe --- /dev/null +++ b/src/server/kernels/pyp5Kernel.ts @@ -0,0 +1,542 @@ +import { KernelMessage } from '@jupyterlab/services'; + +import { PromiseDelegate } from '@lumino/coreutils'; + +import { IDisposable } from '@lumino/disposable'; + +import { IJupyterServer } from '../../tokens'; + +/** + * A kernel that executes code in an IFrame. + */ +export class PyP5KernelIFrame + implements IJupyterServer.IKernelIFrame, IDisposable { + /** + * Instantiate a new IFrameKernel + * + * @param options The instantiation options for a new IFrameKernel + */ + constructor(options: IFrameKernel.IOptions) { + const { id, sendMessage, sessionId } = options; + this._id = id; + // TODO: handle session id + this._sessionId = sessionId; + this._sendMessage = sendMessage; + + // create the main IFrame + this._iframe = document.createElement('iframe'); + this._iframe.style.visibility = 'hidden'; + document.body.appendChild(this._iframe); + + this._initIFrame(this._iframe).then(() => { + // TODO: handle kernel ready + this._jsFunc( + this._iframe.contentWindow, + ` + console._log = console.log; + console._error = console.error; + + window._bubbleUp = function(msg) { + window.parent.postMessage({ + ...msg, + "parentHeader": window._parentHeader + }); + } + + console.log = function() { + const args = Array.prototype.slice.call(arguments); + window._bubbleUp({ + "event": "stream", + "name": "stdout", + "text": args.join(' ') + '\\n' + }); + }; + console.info = console.log; + + console.error = function() { + const args = Array.prototype.slice.call(arguments); + window._bubbleUp({ + "event": "stream", + "name": "stderr", + "text": args.join(' ') + '\\n' + }); + }; + console.warn = console.error; + + window.onerror = function(message, source, lineno, colno, error) { + console.error(message); + } + + setTimeout(() => { + languagePluginLoader.then(() => { + console.log('pyodide loaded!'); + const res = window.pyodide.runPython("1 + 2"); + console.log(res); + }); + }, 1000); + ` + ); + window.addEventListener('message', (e: MessageEvent) => { + const msg = e.data; + const parentHeader = msg.parentHeader as KernelMessage.IHeader< + KernelMessage.MessageType + >; + if (msg.event === 'stream') { + const content = msg as KernelMessage.IStreamMsg['content']; + this._stream(parentHeader, content); + } + }); + }); + } + + /** + * Whether the kernel is disposed. + */ + get isDisposed(): boolean { + return this._isDisposed; + } + + /** + * Get the kernel id + */ + get id(): string { + return this._id; + } + + /** + * Dispose the kernel. + */ + dispose(): void { + if (this.isDisposed) { + return; + } + this._isDisposed = true; + } + + /** + * Register a new IFrame. + * + * @param iframe The IFrame to register. + */ + async registerIFrame(iframe: HTMLIFrameElement): Promise { + await this._initIFrame(iframe); + for (const cell of this._cells) { + this._evalFunc(iframe.contentWindow, cell); + } + // call the preload and setup function + this._jsFunc( + iframe.contentWindow, + ` + setTimeout(() => { + if (window.preload) window.preload(); + else if (window.setup) window.setup(); + window.loop(); + }, 100); + ` + ); + this._iframes.push(iframe); + } + + /** + * Handle an incoming message from the client. + * + * @param msg The message to handle + */ + async handleMessage(msg: KernelMessage.IMessage): Promise { + // console.log(msg) + this._busy(msg); + + const msgType = msg.header.msg_type; + switch (msgType) { + case 'kernel_info_request': + this._kernelInfo(msg); + break; + case 'execute_request': + this._executeRequest(msg); + break; + case 'complete_request': + this._complete(msg); + break; + default: + break; + } + + this._idle(msg); + } + + /** + * Send an 'idle' status message. + * + * @param parent The parent message + */ + private _idle(parent: KernelMessage.IMessage): void { + const message = KernelMessage.createMessage({ + msgType: 'status', + session: '', + parentHeader: parent.header, + channel: 'iopub', + content: { + execution_state: 'idle' + } + }); + this._sendMessage(message); + } + + /** + * Send a 'busy' status message. + * + * @param parent The parent message. + */ + private _busy(parent: KernelMessage.IMessage): void { + const message = KernelMessage.createMessage({ + msgType: 'status', + session: '', + parentHeader: parent.header, + channel: 'iopub', + content: { + execution_state: 'busy' + } + }); + this._sendMessage(message); + } + + /** + * Handle a kernel_info_request message + * + * @param parent The parent message. + */ + private _kernelInfo(parent: KernelMessage.IMessage): void { + const content: KernelMessage.IInfoReply = { + implementation: 'pyp5', + implementation_version: '0.1.0', + language_info: { + codemirror_mode: { + name: 'python', + version: 3 + }, + file_extension: '.py', + mimetype: 'text/x-python', + name: 'python', + nbconvert_exporter: 'python', + pygments_lexer: 'ipython3', + version: '3.8' + }, + protocol_version: '5.3', + status: 'ok', + banner: 'A pyp5js kernel powered by pyodide', + help_links: [ + { + text: 'pyp5js Kernel', + url: 'https://github.com/jtpio/p5-notebook' + } + ] + }; + + const message = KernelMessage.createMessage({ + msgType: 'kernel_info_reply', + channel: 'shell', + session: this._sessionId, + content + }); + + this._sendMessage(message); + } + + /** + * Handle an `execute_request` message + * + * @param msg The parent message. + */ + private _executeRequest(msg: KernelMessage.IMessage): void { + const parent = msg as KernelMessage.IExecuteRequestMsg; + this._execution_count++; + + // store previous parent header + this._jsFunc( + this._iframe.contentWindow, + `window._parentHeader = ${JSON.stringify(parent.header)};` + ); + + this._executeInput(parent); + this._execute(parent); + } + + /** + * Send an `execute_input` message. + * + * @param msg The parent message. + */ + private _executeInput(msg: KernelMessage.IMessage): void { + const parent = msg as KernelMessage.IExecuteInputMsg; + const code = parent.content.code; + const message = KernelMessage.createMessage( + { + msgType: 'execute_input', + parentHeader: parent.header, + channel: 'iopub', + session: this._sessionId, + content: { + code, + execution_count: this._execution_count + } + } + ); + this._sendMessage(message); + } + + /** + * Execute the code. + * + * @param msg The parent message. + */ + private _execute(msg: KernelMessage.IMessage): void { + const parent = msg as KernelMessage.IExecuteRequestMsg; + const code = parent.content.code; + try { + const result = this._eval(code); + this._executeResult(parent, { + data: { + 'text/plain': result + }, + metadata: {} + }); + this._execute_reply(parent, { + execution_count: this._execution_count, + status: 'ok', + user_expressions: {}, + payload: [] + }); + } catch (e) { + const { name, stack, message } = e; + const error = { + ename: name, + evalue: message, + traceback: [stack] + }; + this._error(parent, error); + this._execute_reply(parent, { + execution_count: this._execution_count, + status: 'error', + ...error + }); + } + } + + /** + * Send an `execute_result` message. + * + * @param msg The parent message. + * @param content The execut result content. + */ + private _executeResult( + msg: KernelMessage.IMessage, + content: Pick< + KernelMessage.IExecuteResultMsg['content'], + 'data' | 'metadata' + > + ): void { + const message = KernelMessage.createMessage< + KernelMessage.IExecuteResultMsg + >({ + msgType: 'execute_result', + parentHeader: msg.header, + channel: 'iopub', + session: this._sessionId, + content: { + ...content, + execution_count: this._execution_count + } + }); + this._sendMessage(message); + } + + /** + * Send an `error` message. + * + * @param msg The parent message. + * @param content The content for the execution error response. + */ + private _error( + msg: KernelMessage.IMessage, + content: KernelMessage.IErrorMsg['content'] + ): void { + const message = KernelMessage.createMessage({ + msgType: 'error', + parentHeader: msg.header, + channel: 'iopub', + session: this._sessionId, + content + }); + this._sendMessage(message); + } + + /** + * Send an `execute_reply` message. + * + * @param msg The parent message. + * @param content The content for the execute reply. + */ + private _execute_reply( + msg: KernelMessage.IMessage, + content: KernelMessage.IExecuteReplyMsg['content'] + ): void { + const parent = msg as KernelMessage.IExecuteRequestMsg; + const message = KernelMessage.createMessage( + { + msgType: 'execute_reply', + channel: 'shell', + parentHeader: parent.header, + session: this._sessionId, + content + } + ); + this._sendMessage(message); + } + + /** + * Handle a stream event from the kernel + * + * @param parentHeader The parent header. + * @param content The stream content. + */ + private _stream( + parentHeader: KernelMessage.IHeader, + content: KernelMessage.IStreamMsg['content'] + ): void { + const message = KernelMessage.createMessage({ + channel: 'iopub', + msgType: 'stream', + session: this._sessionId, + parentHeader, + content + }); + this._sendMessage(message); + } + + /** + * Handle an complete_request message + * + * @param msg The parent message. + */ + private _complete(msg: KernelMessage.IMessage): void { + const parent = msg as KernelMessage.ICompleteRequestMsg; + + // naive completion on window names only + // TODO: improve and move logic to the iframe + const vars = this._evalFunc( + this._iframe.contentWindow, + '["test"]' + ) as string[]; + const { code, cursor_pos } = parent.content; + const words = code.slice(0, cursor_pos).match(/(\w+)$/) ?? []; + const word = words[0] ?? ''; + const matches = vars.filter(v => v.startsWith(word)); + + const message = KernelMessage.createMessage< + KernelMessage.ICompleteReplyMsg + >({ + msgType: 'complete_reply', + parentHeader: parent.header, + channel: 'shell', + session: this._sessionId, + content: { + matches, + cursor_start: cursor_pos - word.length, + cursor_end: cursor_pos, + metadata: {}, + status: 'ok' + } + }); + + this._sendMessage(message); + } + + /** + * Execute code in the kernel IFrame. + * + * @param code The code to execute. + */ + private _eval(code: string): string { + // TODO: handle magics + if (code.startsWith('%show')) { + return ''; + } + this._cells.push(code); + for (const frame of this._iframes) { + if (frame?.contentWindow) { + this._evalFunc(frame.contentWindow, code); + } + } + return this._evalFunc(this._iframe.contentWindow, code); + } + + /** + * Create a new IFrame + * + * @param iframe The IFrame to initialize. + */ + private async _initIFrame( + iframe: HTMLIFrameElement + ): Promise { + const delegate = new PromiseDelegate(); + if (!iframe.contentWindow) { + delegate.reject('IFrame not ready'); + return; + } + const doc = iframe.contentWindow.document; + + // add pyodide + const pluginUrl = doc.createElement('script'); + pluginUrl.textContent = + 'window.languagePluginUrl = "https://cdn.jsdelivr.net/pyodide/v0.15.0/full/"'; + doc.head.appendChild(pluginUrl); + + const script = doc.createElement('script'); + script.src = 'https://cdn.jsdelivr.net/pyodide/v0.15.0/full/pyodide.js'; + doc.head.appendChild(script); + + delegate.resolve(); + await delegate.promise; + return iframe; + } + + private _id: string; + private _iframe: HTMLIFrameElement; + private _iframes: HTMLIFrameElement[] = []; + private _cells: string[] = []; + private _jsFunc = new Function('window', 'code', 'return window.eval(code);'); + private _evalFunc = new Function( + 'window', + 'code', + 'return window.pyodide.runPython(code);' + ); + private _execution_count = 0; + private _sessionId: string; + private _isDisposed = false; + private _sendMessage: (msg: KernelMessage.IMessage) => void; +} + +/** + * A namespace for IFrameKernel statics. + */ +export namespace IFrameKernel { + /** + * The instantiation options for an IFrameKernel. + */ + export interface IOptions { + /** + * The kernel id. + */ + id: string; + + /** + * The session id. + */ + sessionId: string; + + /** + * The method to send messages back to the server. + */ + sendMessage: (msg: KernelMessage.IMessage) => void; + } +} diff --git a/src/server/kernelspecs.ts b/src/server/kernelspecs.ts index 41b9dea..6da655c 100644 --- a/src/server/kernelspecs.ts +++ b/src/server/kernelspecs.ts @@ -71,6 +71,24 @@ namespace Private { 'logo-32x32': '/kernelspecs/p5/logo-32x32.png', 'logo-64x64': '/build/resources/p5js-square-logo.svg' } + }, + pyp5: { + name: 'pyp5', + display_name: 'pyp5js', + language: 'python', + argv: [], + spec: { + argv: [], + env: {}, + display_name: 'pyp5js', + language: 'python', + interrupt_mode: 'message', + metadata: {} + }, + resources: { + 'logo-32x32': '/kernelspecs/p5/logo-32x32.png', + 'logo-64x64': '/build/resources/pyp5js.svg' + } } } }; diff --git a/src/server/sessions.ts b/src/server/sessions.ts index fbb505d..21b54c0 100644 --- a/src/server/sessions.ts +++ b/src/server/sessions.ts @@ -53,8 +53,9 @@ export class Sessions implements IJupyterServer.IRoutable { */ startNew(options: Session.IModel): Session.IModel { const { path, name } = options; + const kernelName = options.kernel?.name; const id = options.id ?? UUID.uuid4(); - const kernel = this._kernels.startNew(id); + const kernel = this._kernels.startNew({ id, name: kernelName }); const session: Session.IModel = { id, path: path ?? name, diff --git a/src/tokens.ts b/src/tokens.ts index 4ac7c66..e19adb7 100644 --- a/src/tokens.ts +++ b/src/tokens.ts @@ -8,10 +8,15 @@ export namespace IJupyterServer { * An interface for a IFramed based kernel. */ export interface IKernelIFrame { + /** + * Dispose the kernel. + */ + dispose(): void; + /** * Register an IFrame from the frontend. */ - registerIFrame(iframe: HTMLIFrameElement): void; + registerIFrame(iframe: HTMLIFrameElement): Promise; /** * Handle an incoming message from the server. diff --git a/stage.js b/stage.js index 87d967c..8b3a895 100644 --- a/stage.js +++ b/stage.js @@ -9,7 +9,8 @@ fs.ensureDirSync(dest); 'index.html', 'favicon.ico', './build/bundle.js', - './build/resources/p5js-square-logo.svg' + './build/resources/p5js-square-logo.svg', + './build/resources/pyp5js.svg' ].forEach(f => { const src = path.resolve(basePath, f); const target = path.resolve(dest, f);