/ concept-collection / jupyterlite-numbl-kernel
Sign in
concept-collection / jupyterlite-numbl-kernel
jupyterlite-numbl-kernel / src / kernel.ts
267 lines · 8.4 KBBlameHistoryRaw
1import type { Contents, KernelMessage } from '@jupyterlab/services';
3import { BaseKernel } from '@jupyterlite/services';
4import type { IKernel } from '@jupyterlite/services';
6import { createNumblSession } from 'numbl/browser';
7import type { NumblSession } from 'numbl/browser';
9/** Mime type carrying a cell's plot instructions (see src/mime.tsx). */
10export const FIGURE_MIME = 'application/vnd.numbl.figure+json';
12/**
13 * A JupyterLite kernel that executes MATLAB-syntax code with numbl,
14 * entirely in the browser.
15 *
16 * Each kernel owns one numbl session (a Web Worker managed by numbl):
17 * variables persist across cells, console output streams to the running
18 * cell, and figures are published as display_data with the numbl figure
19 * mime type. Restarting the kernel disposes the session, so the next
20 * execution boots a fresh workspace.
21 *
22 * Before every execution, `.m` files sitting next to the notebook (in the
23 * JupyterLite contents) are synced into the session, so named functions can
24 * be defined in the file browser, edited in the Jupyter editor, and called
25 * from cells — numbl rescans the working directory on each execution, so
26 * edits apply on the next run. The sync is one-way and additive: deleting a
27 * `.m` file leaves its function defined until the kernel restarts.
28 */
29export class NumblKernel extends BaseKernel {
30 /**
31 * @param options Standard kernel options.
32 * @param contents The (browser-side) contents manager used to read `.m`
33 * workspace files from the notebook's directory.
34 */
35 constructor(options: IKernel.IOptions, contents?: Contents.IManager) {
36 super(options);
37 this._contents = contents ?? null;
38 }
39 /**
40 * Handle a kernel_info_request message.
41 */
42 async kernelInfoRequest(): Promise<KernelMessage.IInfoReplyMsg['content']> {
43 const content: KernelMessage.IInfoReply = {
44 implementation: 'numbl',
45 implementation_version: '0.1.0',
46 language_info: {
47 codemirror_mode: 'octave',
48 file_extension: '.m',
49 mimetype: 'text/x-octave',
50 name: 'matlab',
51 nbconvert_exporter: 'script',
52 pygments_lexer: 'matlab',
53 version: 'numbl'
54 },
55 protocol_version: '5.3',
56 status: 'ok',
57 banner:
58 'numbl: MATLAB-syntax numerical computing, running in the browser',
59 help_links: [
60 {
61 text: 'numbl',
62 url: 'https://github.com/flatironinstitute/numbl'
63 }
64 ]
65 };
66 return content;
67 }
69 /**
70 * Handle an `execute_request` message: run the cell against the numbl
71 * session's persistent workspace.
72 */
73 async executeRequest(
74 content: KernelMessage.IExecuteRequestMsg['content']
75 ): Promise<KernelMessage.IExecuteReplyMsg['content']> {
76 let session: NumblSession;
77 try {
78 session = await this._sessionPromise();
79 } catch (err) {
80 // Boot failure (e.g. the mip download was unreachable). Reset so a
81 // later cell can retry, and report the failure on this cell.
82 this._session = null;
83 const message = err instanceof Error ? err.message : String(err);
84 return this._errorReply(
85 'SessionError',
86 `Failed to start numbl: ${message}`
87 );
88 }
90 await this._syncWorkspaceFiles(session);
91 const result = await session.execute(content.code);
93 if (!result.ok) {
94 return this._errorReply('NumblError', result.error ?? 'Unknown error');
95 }
97 if (result.plotInstructions.length > 0) {
98 // Round-trip through JSON so the live render path sees exactly what a
99 // reloaded notebook sees (structured-clone NaNs become nulls; the
100 // renderer restores them).
101 const instructions = JSON.parse(JSON.stringify(result.plotInstructions));
102 this.displayData({
103 data: {
104 [FIGURE_MIME]: { version: 1, plotInstructions: instructions },
105 'text/plain':
106 '<numbl figure — install jupyterlite-numbl-kernel to render>'
107 },
108 metadata: {}
109 });
110 }
112 return {
113 status: 'ok',
114 execution_count: this.executionCount,
115 user_expressions: {}
116 };
117 }
119 /**
120 * Handle a `complete_request` message. Completion is not implemented.
121 */
122 async completeRequest(
123 content: KernelMessage.ICompleteRequestMsg['content']
124 ): Promise<KernelMessage.ICompleteReplyMsg['content']> {
125 return {
126 status: 'ok',
127 matches: [],
128 cursor_start: content.cursor_pos,
129 cursor_end: content.cursor_pos,
130 metadata: {}
131 };
132 }
134 /**
135 * Handle an `inspect_request` message. Inspection is not implemented.
136 */
137 async inspectRequest(
138 content: KernelMessage.IInspectRequestMsg['content']
139 ): Promise<KernelMessage.IInspectReplyMsg['content']> {
140 return { status: 'ok', found: false, data: {}, metadata: {} };
141 }
143 /**
144 * Handle an `is_complete_request` message: treat every submission as a
145 * complete MATLAB statement (the console runs on Enter).
146 */
147 async isCompleteRequest(
148 content: KernelMessage.IIsCompleteRequestMsg['content']
149 ): Promise<KernelMessage.IIsCompleteReplyMsg['content']> {
150 return { status: 'complete' };
151 }
153 /**
154 * Handle a `comm_info_request` message. Comms are not implemented.
155 */
156 async commInfoRequest(
157 content: KernelMessage.ICommInfoRequestMsg['content']
158 ): Promise<KernelMessage.ICommInfoReplyMsg['content']> {
159 return { status: 'ok', comms: {} };
160 }
162 /**
163 * Send an `input_reply` message. stdin is not supported.
164 */
165 inputReply(content: KernelMessage.IInputReplyMsg['content']): void {
166 // no-op
167 }
169 async commOpen(msg: KernelMessage.ICommOpenMsg): Promise<void> {
170 // no-op
171 }
173 async commMsg(msg: KernelMessage.ICommMsgMsg): Promise<void> {
174 // no-op
175 }
177 async commClose(msg: KernelMessage.ICommCloseMsg): Promise<void> {
178 // no-op
179 }
181 /**
182 * Dispose the kernel and its numbl session (worker).
183 */
184 dispose(): void {
185 if (this.isDisposed) {
186 return;
187 }
188 void this._session?.then(s => s.dispose()).catch(() => undefined);
189 this._session = null;
190 super.dispose();
191 }
193 /**
194 * Sync `.m` files from the notebook's directory into the session VFS.
195 * numbl rescans its working directory on every execution, so a file
196 * written here becomes callable on this same execute() call, and an
197 * edit made in the Jupyter editor takes effect the next time a cell runs.
198 * Best-effort: a contents-manager error (e.g. no browser drive mounted)
199 * just skips the sync rather than failing the cell.
200 */
201 private async _syncWorkspaceFiles(session: NumblSession): Promise<void> {
202 if (!this._contents) {
203 return;
204 }
205 const dir = this.location;
206 let listing: Contents.IModel;
207 try {
208 listing = await this._contents.get(dir, { content: true });
209 } catch {
210 return;
211 }
212 const files = Array.isArray(listing.content) ? listing.content : [];
213 for (const entry of files as Contents.IModel[]) {
214 if (entry.type !== 'file' || !entry.name.endsWith('.m')) {
215 continue;
216 }
217 if (this._syncedMTimes.get(entry.path) === entry.last_modified) {
218 continue;
219 }
220 try {
221 const file = await this._contents.get(entry.path, {
222 content: true,
223 type: 'file',
224 format: 'text'
225 });
226 session.writeFile(entry.name, String(file.content));
227 this._syncedMTimes.set(entry.path, entry.last_modified);
228 } catch {
229 // Skip this file; other workspace files still sync.
230 }
231 }
232 }
234 /**
235 * Boot the numbl session lazily on first use, so creating the kernel is
236 * instant and boot progress (mip download, cached-package restore) streams
237 * into the first executed cell.
238 */
239 private _sessionPromise(): Promise<NumblSession> {
240 this._session ??= createNumblSession({
241 onOutput: text => this.stream({ name: 'stdout', text }),
242 onProgress: message =>
243 this.stream({ name: 'stdout', text: `[numbl] ${message}\n` })
244 });
245 return this._session;
246 }
248 private _errorReply(
249 ename: string,
250 formatted: string
251 ): KernelMessage.IExecuteReplyMsg['content'] {
252 const traceback = formatted.split('\n');
253 const evalue = traceback[0] ?? '';
254 this.publishExecuteError({ ename, evalue, traceback });
255 return {
256 status: 'error',
257 execution_count: this.executionCount,
258 ename,
259 evalue,
260 traceback
261 };
262 }
264 private _session: Promise<NumblSession> | null = null;
265 private readonly _contents: Contents.IManager | null;
266 private readonly _syncedMTimes = new Map<string, string>();
moveopenescclose