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 runs numbl — a MATLAB-syntax numerical
14 * computing engine — 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 // numbl uses MATLAB syntax; the octave/matlab modes drive
48 // highlighting in the notebook and on nbconvert export.
49 codemirror_mode: 'octave',
50 file_extension: '.m',
51 mimetype: 'text/x-octave',
52 name: 'numbl',
53 nbconvert_exporter: 'script',
54 pygments_lexer: 'matlab',
55 version: 'numbl'
56 },
57 protocol_version: '5.3',
58 status: 'ok',
59 banner:
60 'numbl: MATLAB-syntax numerical computing, running in the browser',
61 help_links: [
62 {
63 text: 'numbl',
64 url: 'https://github.com/flatironinstitute/numbl'
65 }
66 ]
67 };
68 return content;
69 }
71 /**
72 * Handle an `execute_request` message: run the cell against the numbl
73 * session's persistent workspace.
74 */
75 async executeRequest(
76 content: KernelMessage.IExecuteRequestMsg['content']
77 ): Promise<KernelMessage.IExecuteReplyMsg['content']> {
78 let session: NumblSession;
79 try {
80 session = await this._sessionPromise();
81 } catch (err) {
82 // Boot failure (e.g. the mip download was unreachable). Reset so a
83 // later cell can retry, and report the failure on this cell.
84 this._session = null;
85 const message = err instanceof Error ? err.message : String(err);
86 return this._errorReply(
87 'SessionError',
88 `Failed to start numbl: ${message}`
89 );
90 }
92 await this._syncWorkspaceFiles(session);
93 const result = await session.execute(content.code);
95 if (!result.ok) {
96 return this._errorReply('NumblError', result.error ?? 'Unknown error');
97 }
99 if (result.plotInstructions.length > 0) {
100 // Round-trip through JSON so the live render path sees exactly what a
101 // reloaded notebook sees (structured-clone NaNs become nulls; the
102 // renderer restores them).
103 const instructions = JSON.parse(JSON.stringify(result.plotInstructions));
104 this.displayData({
105 data: {
106 [FIGURE_MIME]: { version: 1, plotInstructions: instructions },
107 'text/plain':
108 '<numbl figure — install jupyterlite-numbl-kernel to render>'
109 },
110 metadata: {}
111 });
112 }
114 return {
115 status: 'ok',
116 execution_count: this.executionCount,
117 user_expressions: {}
118 };
119 }
121 /**
122 * Handle a `complete_request` message. Completion is not implemented.
123 */
124 async completeRequest(
125 content: KernelMessage.ICompleteRequestMsg['content']
126 ): Promise<KernelMessage.ICompleteReplyMsg['content']> {
127 return {
128 status: 'ok',
129 matches: [],
130 cursor_start: content.cursor_pos,
131 cursor_end: content.cursor_pos,
132 metadata: {}
133 };
134 }
136 /**
137 * Handle an `inspect_request` message. Inspection is not implemented.
138 */
139 async inspectRequest(
140 content: KernelMessage.IInspectRequestMsg['content']
141 ): Promise<KernelMessage.IInspectReplyMsg['content']> {
142 return { status: 'ok', found: false, data: {}, metadata: {} };
143 }
145 /**
146 * Handle an `is_complete_request` message: treat every submission as a
147 * complete numbl statement (the console runs on Enter).
148 */
149 async isCompleteRequest(
150 content: KernelMessage.IIsCompleteRequestMsg['content']
151 ): Promise<KernelMessage.IIsCompleteReplyMsg['content']> {
152 return { status: 'complete' };
153 }
155 /**
156 * Handle a `comm_info_request` message. Comms are not implemented.
157 */
158 async commInfoRequest(
159 content: KernelMessage.ICommInfoRequestMsg['content']
160 ): Promise<KernelMessage.ICommInfoReplyMsg['content']> {
161 return { status: 'ok', comms: {} };
162 }
164 /**
165 * Send an `input_reply` message. stdin is not supported.
166 */
167 inputReply(content: KernelMessage.IInputReplyMsg['content']): void {
168 // no-op
169 }
171 async commOpen(msg: KernelMessage.ICommOpenMsg): Promise<void> {
172 // no-op
173 }
175 async commMsg(msg: KernelMessage.ICommMsgMsg): Promise<void> {
176 // no-op
177 }
179 async commClose(msg: KernelMessage.ICommCloseMsg): Promise<void> {
180 // no-op
181 }
183 /**
184 * Dispose the kernel and its numbl session (worker).
185 */
186 dispose(): void {
187 if (this.isDisposed) {
188 return;
189 }
190 void this._session?.then(s => s.dispose()).catch(() => undefined);
191 this._session = null;
192 super.dispose();
193 }
195 /**
196 * Sync `.m` files from the notebook's directory into the session VFS.
197 * numbl rescans its working directory on every execution, so a file
198 * written here becomes callable on this same execute() call, and an
199 * edit made in the Jupyter editor takes effect the next time a cell runs.
200 * Best-effort: a contents-manager error (e.g. no browser drive mounted)
201 * just skips the sync rather than failing the cell.
202 */
203 private async _syncWorkspaceFiles(session: NumblSession): Promise<void> {
204 if (!this._contents) {
205 return;
206 }
207 const dir = this.location;
208 let listing: Contents.IModel;
209 try {
210 listing = await this._contents.get(dir, { content: true });
211 } catch {
212 return;
213 }
214 const files = Array.isArray(listing.content) ? listing.content : [];
215 for (const entry of files as Contents.IModel[]) {
216 if (entry.type !== 'file' || !entry.name.endsWith('.m')) {
217 continue;
218 }
219 if (this._syncedMTimes.get(entry.path) === entry.last_modified) {
220 continue;
221 }
222 try {
223 const file = await this._contents.get(entry.path, {
224 content: true,
225 type: 'file',
226 format: 'text'
227 });
228 session.writeFile(entry.name, String(file.content));
229 this._syncedMTimes.set(entry.path, entry.last_modified);
230 } catch {
231 // Skip this file; other workspace files still sync.
232 }
233 }
234 }
236 /**
237 * Boot the numbl session lazily on first use, so creating the kernel is
238 * instant and boot progress (mip download, cached-package restore) streams
239 * into the first executed cell.
240 */
241 private _sessionPromise(): Promise<NumblSession> {
242 this._session ??= createNumblSession({
243 onOutput: text => this.stream({ name: 'stdout', text }),
244 onProgress: message =>
245 this.stream({ name: 'stdout', text: `[numbl] ${message}\n` })
246 });
247 return this._session;
248 }
250 private _errorReply(
251 ename: string,
252 formatted: string
253 ): KernelMessage.IExecuteReplyMsg['content'] {
254 const traceback = formatted.split('\n');
255 const evalue = traceback[0] ?? '';
256 this.publishExecuteError({ ename, evalue, traceback });
257 return {
258 status: 'error',
259 execution_count: this.executionCount,
260 ename,
261 evalue,
262 traceback
263 };
264 }
266 private _session: Promise<NumblSession> | null = null;
267 private readonly _contents: Contents.IManager | null;
268 private readonly _syncedMTimes = new Map<string, string>();
269}