diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index 4782ab9..54a2635 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -109,10 +109,10 @@ jobs:
name: extension-artifacts
- name: Install the dependencies
run: |
- python -m pip install --pre jupyterlite-core jupyterlite_javascript_kernel*.whl
+ python -m pip install --pre jupyterlite-core jupyter-server jupyterlite_javascript_kernel*.whl
- name: Build the JupyterLite site
run: |
- jupyter lite build --output-dir dist
+ jupyter lite build --output-dir dist --contents examples
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
diff --git a/.readthedocs.yml b/.readthedocs.yml
index 6c9b48c..d329605 100644
--- a/.readthedocs.yml
+++ b/.readthedocs.yml
@@ -7,6 +7,6 @@ build:
commands:
- mamba env update --name base --file docs/environment.yml
- python -m pip install .
- - jupyter lite build --output-dir dist
+ - jupyter lite build --output-dir dist --contents examples
- mkdir -p $READTHEDOCS_OUTPUT/html
- cp -r dist/* $READTHEDOCS_OUTPUT/html/
diff --git a/README.md b/README.md
index 4851b03..d1f8eac 100644
--- a/README.md
+++ b/README.md
@@ -29,6 +29,115 @@ To remove the extension, execute:
pip uninstall jupyterlite-javascript-kernel
```
+## Runtime modes
+
+The extension currently registers two JavaScript kernelspecs:
+
+- `JavaScript (IFrame)`:
+ Runs code in a hidden runtime `iframe` on the main page thread. Use this when your code needs browser DOM APIs like `document`, `window`, or canvas access through the page context.
+- `JavaScript (Web Worker)`:
+ Runs code in a dedicated Web Worker. Use this for stronger isolation and to avoid blocking the main UI thread.
+
+Pick either kernel from the notebook kernel selector in JupyterLite.
+
+### Worker mode limitations
+
+Web Workers do not expose DOM APIs. In `JavaScript (Web Worker)`, APIs such as `document`, direct element access, and other main-thread-only browser APIs are unavailable.
+
+### Import side effects in iframe mode
+
+In `JavaScript (IFrame)`, user code and imports execute in the runtime iframe scope.
+
+By default, module-level side effects stay in the runtime iframe. To intentionally affect the main page (`window.parent`), access it directly.
+
+Cell declarations like `var`, `let`, `const`, `function`, and `class` remain in the runtime scope. Host-page mutations happen when your code (or imported code) explicitly reaches `window.parent`.
+
+#### Example: canvas-confetti
+
+```javascript
+import confetti from 'canvas-confetti';
+
+const canvas = window.parent.document.createElement('canvas');
+Object.assign(canvas.style, {
+ position: 'fixed',
+ inset: '0',
+ width: '100%',
+ height: '100%',
+ pointerEvents: 'none',
+ zIndex: '2147483647'
+});
+window.parent.document.body.appendChild(canvas);
+
+const fire = confetti.create(canvas, { resize: true, useWorker: true });
+
+fire({ particleCount: 20, spread: 70 });
+```
+
+#### Example: p5.js
+
+```javascript
+import p5 from 'p5';
+
+const mount = window.parent.document.createElement('div');
+Object.assign(mount.style, {
+ position: 'fixed',
+ right: '16px',
+ bottom: '16px',
+ zIndex: '1000'
+});
+window.parent.document.body.appendChild(mount);
+
+const sketch = new p5(p => {
+ p.setup = () => {
+ p.createCanvas(120, 80);
+ p.noLoop();
+ };
+}, mount);
+```
+
+#### Can side effects be auto-detected and cleaned up?
+
+Partially, yes, but not perfectly. This project currently does not provide automatic side-effect cleanup for host-page mutations.
+
+Limits of automatic cleanup:
+
+- It will not reliably undo monkey-patched globals.
+- It will not automatically remove all event listeners or timers.
+- It cannot safely revert all stateful third-party module internals.
+
+### Enable or disable specific modes
+
+The two runtime modes are registered by separate plugins:
+
+- `@jupyterlite/javascript-kernel-extension:kernel-iframe`
+- `@jupyterlite/javascript-kernel-extension:kernel-worker`
+
+You can disable either one using `disabledExtensions` in `jupyter-config-data`.
+
+Disable worker mode:
+
+```json
+{
+ "jupyter-config-data": {
+ "disabledExtensions": [
+ "@jupyterlite/javascript-kernel-extension:kernel-worker"
+ ]
+ }
+}
+```
+
+Disable iframe mode:
+
+```json
+{
+ "jupyter-config-data": {
+ "disabledExtensions": [
+ "@jupyterlite/javascript-kernel-extension:kernel-iframe"
+ ]
+ }
+}
+```
+
## Contributing
### Development install
diff --git a/examples/intro.ipynb b/examples/intro.ipynb
new file mode 100644
index 0000000..10d2597
--- /dev/null
+++ b/examples/intro.ipynb
@@ -0,0 +1,276 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# JavaScript Kernel Introduction\n",
+ "\n",
+ "This notebook demonstrates the basic features of the JupyterLite JavaScript kernel."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Basic Expressions\n",
+ "\n",
+ "The last expression in a cell is automatically returned (unless it ends with a semicolon):"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "1 + 2 + 3"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "\"Hello, \" + \"JavaScript!\""
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "// Adding a semicolon suppresses output\n",
+ "const x = 42;"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "x * 2"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Variables Persist Across Cells\n",
+ "\n",
+ "Variables defined in one cell are available in subsequent cells:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "const greeting = \"Hello\";\n",
+ "const name = \"World\";"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "`${greeting}, ${name}!`"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Functions"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "function factorial(n) {\n",
+ " if (n <= 1) return 1;\n",
+ " return n * factorial(n - 1);\n",
+ "}"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "factorial(10)"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "// Arrow functions\n",
+ "const square = x => x * x;\n",
+ "const numbers = [1, 2, 3, 4, 5];\n",
+ "numbers.map(square)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Console Output\n",
+ "\n",
+ "`console.log()` and `console.error()` output is captured:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "console.log(\"This is a log message\");\n",
+ "console.log(\"Multiple\", \"arguments\", \"work\", \"too\");"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "console.error(\"This is an error message\");\n",
+ "console.warn(\"This is a warning\");"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Async/Await\n",
+ "\n",
+ "Top-level await is supported:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "const delay = ms => new Promise(resolve => setTimeout(resolve, ms));\n",
+ "\n",
+ "console.log(\"Starting...\");\n",
+ "await delay(1000);\n",
+ "console.log(\"Done after 1 second!\");"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "// Async function example\n",
+ "async function fetchData() {\n",
+ " await delay(500);\n",
+ " return { status: \"success\", data: [1, 2, 3] };\n",
+ "}\n",
+ "\n",
+ "await fetchData()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Classes"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "class Point {\n",
+ " constructor(x, y) {\n",
+ " this.x = x;\n",
+ " this.y = y;\n",
+ " }\n",
+ " \n",
+ " distance(other) {\n",
+ " const dx = this.x - other.x;\n",
+ " const dy = this.y - other.y;\n",
+ " return Math.sqrt(dx * dx + dy * dy);\n",
+ " }\n",
+ " \n",
+ " toString() {\n",
+ " return `Point(${this.x}, ${this.y})`;\n",
+ " }\n",
+ "}"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "const p1 = new Point(0, 0);\n",
+ "const p2 = new Point(3, 4);\n",
+ "p1.distance(p2)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Error Handling\n",
+ "\n",
+ "Errors are displayed with stack traces:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "function outer() {\n",
+ " inner();\n",
+ "}\n",
+ "\n",
+ "function inner() {\n",
+ " throw new Error(\"Something went wrong!\");\n",
+ "}\n",
+ "\n",
+ "outer()"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "JavaScript",
+ "language": "javascript",
+ "name": "javascript"
+ },
+ "language_info": {
+ "file_extension": ".js",
+ "mimetype": "text/javascript",
+ "name": "javascript",
+ "version": "ES2021"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 4
+}
diff --git a/examples/magic-imports.ipynb b/examples/magic-imports.ipynb
new file mode 100644
index 0000000..8543876
--- /dev/null
+++ b/examples/magic-imports.ipynb
@@ -0,0 +1,238 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# Magic Imports\n",
+ "\n",
+ "The JavaScript kernel supports **magic imports** - you can import npm packages directly and they'll be automatically fetched from a CDN (jsdelivr by default)."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Importing npm Packages\n",
+ "\n",
+ "Just use standard ES module import syntax with a package name.\n",
+ "\n",
+ "For host-page visuals in iframe mode, target `window.parent` explicitly.\n"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import confetti from 'canvas-confetti';\n",
+ "\n",
+ "const canvas = window.parent.document.createElement('canvas');\n",
+ "Object.assign(canvas.style, {\n",
+ " position: 'fixed',\n",
+ " inset: '0',\n",
+ " width: '100%',\n",
+ " height: '100%',\n",
+ " pointerEvents: 'none',\n",
+ " zIndex: '2147483647'\n",
+ "});\n",
+ "window.parent.document.body.appendChild(canvas);\n",
+ "\n",
+ "const fire = confetti.create(canvas, {\n",
+ " resize: true,\n",
+ " useWorker: true\n",
+ "});\n",
+ "\n",
+ "fire({\n",
+ " particleCount: 100,\n",
+ " spread: 70,\n",
+ " origin: { y: 0.6 }\n",
+ "});\n"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Using Lodash"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import _ from 'lodash';\n",
+ "\n",
+ "const data = [\n",
+ " { name: 'Alice', age: 30, city: 'NYC' },\n",
+ " { name: 'Bob', age: 25, city: 'LA' },\n",
+ " { name: 'Charlie', age: 35, city: 'NYC' },\n",
+ " { name: 'Diana', age: 28, city: 'LA' }\n",
+ "];\n",
+ "\n",
+ "_.groupBy(data, 'city')"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "// More lodash examples\n",
+ "_.chain(data)\n",
+ " .filter(p => p.age > 26)\n",
+ " .sortBy('age')\n",
+ " .map('name')\n",
+ " .value()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Named Imports"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import { format, formatDistance } from 'date-fns';\n",
+ "\n",
+ "const now = new Date();\n",
+ "const past = new Date(2020, 0, 1);\n",
+ "\n",
+ "console.log('Formatted:', format(now, 'yyyy-MM-dd HH:mm:ss'));\n",
+ "console.log('Distance:', formatDistance(past, now, { addSuffix: true }));"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Specifying Versions\n",
+ "\n",
+ "You can specify package versions using the npm syntax:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import { marked } from 'marked@9.0.0';\n",
+ "\n",
+ "const markdown = `\n",
+ "# Hello Markdown!\n",
+ "\n",
+ "This is **bold** and this is *italic*.\n",
+ "\n",
+ "- Item 1\n",
+ "- Item 2\n",
+ "- Item 3\n",
+ "`;\n",
+ "\n",
+ "marked(markdown)"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Using D3.js for Data Visualization"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": "import * as d3 from 'd3';\n\n// Create SVG bar chart\nconst data = [30, 86, 168, 281, 303, 365];\nconst width = 400;\nconst height = 200;\nconst barHeight = 25;\n\nconst x = d3.scaleLinear()\n .domain([0, d3.max(data)])\n .range([0, width - 50]);\n\nconst svg = d3.create(\"svg\")\n .attr(\"width\", width)\n .attr(\"height\", height)\n .attr(\"viewBox\", [0, 0, width, height]);\n\nconst bar = svg.selectAll(\"g\")\n .data(data)\n .join(\"g\")\n .attr(\"transform\", (d, i) => `translate(0,${i * barHeight})`);\n\nbar.append(\"rect\")\n .attr(\"fill\", \"steelblue\")\n .attr(\"width\", d => x(d))\n .attr(\"height\", barHeight - 2);\n\nbar.append(\"text\")\n .attr(\"fill\", \"white\")\n .attr(\"x\", d => x(d) - 5)\n .attr(\"y\", barHeight / 2)\n .attr(\"dy\", \"0.35em\")\n .attr(\"text-anchor\", \"end\")\n .text(d => d);\n\nsvg.node().outerHTML"
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## URL Imports\n",
+ "\n",
+ "You can also import from full URLs:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "import dayjs from 'https://cdn.jsdelivr.net/npm/dayjs@1.11.10/+esm';\n",
+ "\n",
+ "dayjs().format('MMMM D, YYYY h:mm A')"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Side-Effect Imports\n",
+ "\n",
+ "Some packages just need to be loaded for their side effects:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "// Import for side effects only\n",
+ "import 'chart.js';\n",
+ "\n",
+ "// Chart.js is now available globally\n",
+ "console.log('Chart.js loaded:', typeof Chart !== 'undefined');"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Working with JSON Data"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "// Fetch JSON data from the web\n",
+ "const response = await fetch('https://jsonplaceholder.typicode.com/users');\n",
+ "const users = await response.json();\n",
+ "\n",
+ "// Use lodash to process\n",
+ "_.take(users.map(u => ({ name: u.name, email: u.email })), 3)"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "JavaScript",
+ "language": "javascript",
+ "name": "javascript"
+ },
+ "language_info": {
+ "file_extension": ".js",
+ "mimetype": "text/javascript",
+ "name": "javascript",
+ "version": "ES2021"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 4
+}
diff --git a/examples/rich-output.ipynb b/examples/rich-output.ipynb
new file mode 100644
index 0000000..762e843
--- /dev/null
+++ b/examples/rich-output.ipynb
@@ -0,0 +1,413 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# Rich Output\n",
+ "\n",
+ "The JavaScript kernel supports rich MIME output for various data types."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Arrays and Objects\n",
+ "\n",
+ "Arrays and objects are displayed with nice formatting:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "[1, 2, 3, 4, 5]"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": "const person = {\n name: 'Alice',\n age: 30,\n hobbies: ['reading', 'coding', 'hiking'],\n address: {\n city: 'New York',\n country: 'USA'\n }\n};\n\nperson"
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Maps and Sets"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "const map = new Map();\n",
+ "map.set('a', 1);\n",
+ "map.set('b', 2);\n",
+ "map.set('c', 3);\n",
+ "map"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "new Set([1, 2, 3, 2, 1, 4, 5])"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Dates"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "new Date()"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Regular Expressions"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "/^hello\\s+world$/gi"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Functions"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "function greet(name, greeting = 'Hello') {\n",
+ " return `${greeting}, ${name}!`;\n",
+ "}\n",
+ "\n",
+ "greet"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "// Arrow function\n",
+ "(x, y) => x + y"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## BigInt"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "BigInt(\"9007199254740993\")"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Symbols"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "Symbol('my-symbol')"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Typed Arrays"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "new Float64Array([1.1, 2.2, 3.3, 4.4, 5.5])"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Canvas Output\n",
+ "\n",
+ "Canvas elements are rendered as images:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "const canvas = document.createElement('canvas');\n",
+ "canvas.width = 400;\n",
+ "canvas.height = 200;\n",
+ "const ctx = canvas.getContext('2d');\n",
+ "\n",
+ "// Draw a gradient background\n",
+ "const gradient = ctx.createLinearGradient(0, 0, 400, 0);\n",
+ "gradient.addColorStop(0, '#ff6b6b');\n",
+ "gradient.addColorStop(0.5, '#4ecdc4');\n",
+ "gradient.addColorStop(1, '#45b7d1');\n",
+ "ctx.fillStyle = gradient;\n",
+ "ctx.fillRect(0, 0, 400, 200);\n",
+ "\n",
+ "// Draw some text\n",
+ "ctx.fillStyle = 'white';\n",
+ "ctx.font = 'bold 36px sans-serif';\n",
+ "ctx.textAlign = 'center';\n",
+ "ctx.fillText('Hello Canvas!', 200, 110);\n",
+ "\n",
+ "canvas"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "// Draw shapes\n",
+ "const canvas2 = document.createElement('canvas');\n",
+ "canvas2.width = 400;\n",
+ "canvas2.height = 200;\n",
+ "const ctx2 = canvas2.getContext('2d');\n",
+ "\n",
+ "ctx2.fillStyle = '#f0f0f0';\n",
+ "ctx2.fillRect(0, 0, 400, 200);\n",
+ "\n",
+ "// Draw circles\n",
+ "const colors = ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4', '#ffeaa7'];\n",
+ "for (let i = 0; i < 5; i++) {\n",
+ " ctx2.beginPath();\n",
+ " ctx2.arc(50 + i * 75, 100, 30, 0, Math.PI * 2);\n",
+ " ctx2.fillStyle = colors[i];\n",
+ " ctx2.fill();\n",
+ "}\n",
+ "\n",
+ "canvas2"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## DOM Elements\n",
+ "\n",
+ "HTMLElement and SVGElement instances should render as rich output:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "const card = document.createElement('div');\n",
+ "card.style.cssText = 'padding:12px;border:1px solid #dadada;border-radius:8px;background:#fffbe6;font-family:sans-serif;';\n",
+ "card.innerHTML = 'DOM element test
Rendered from runtime iframe scope.';\n",
+ "\n",
+ "card"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');\n",
+ "svg.setAttribute('width', '220');\n",
+ "svg.setAttribute('height', '80');\n",
+ "\n",
+ "const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');\n",
+ "rect.setAttribute('width', '220');\n",
+ "rect.setAttribute('height', '80');\n",
+ "rect.setAttribute('rx', '10');\n",
+ "rect.setAttribute('fill', '#d9f2ff');\n",
+ "\n",
+ "const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');\n",
+ "text.setAttribute('x', '16');\n",
+ "text.setAttribute('y', '46');\n",
+ "text.setAttribute('font-size', '20');\n",
+ "text.setAttribute('font-family', 'sans-serif');\n",
+ "text.textContent = 'SVG element test';\n",
+ "\n",
+ "svg.append(rect, text);\n",
+ "svg"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## HTML Strings\n",
+ "\n",
+ "Strings that look like HTML are rendered as HTML:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "'
Hello HTML!
This is rendered as HTML because it starts and ends with tags.
'"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Custom MIME Output\n",
+ "\n",
+ "Objects can define custom output methods:"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "// Object with custom _toHtml method\n",
+ "const chart = {\n",
+ " data: [30, 50, 80, 40, 60],\n",
+ " _toHtml() {\n",
+ " const max = Math.max(...this.data);\n",
+ " const bars = this.data.map((v, i) => {\n",
+ " const height = (v / max) * 100;\n",
+ " const hue = (i * 60) % 360;\n",
+ " return ``;\n",
+ " }).join('');\n",
+ " return `${bars}
`;\n",
+ " },\n",
+ " toString() {\n",
+ " return `Chart(${this.data.length} bars)`;\n",
+ " }\n",
+ "};\n",
+ "\n",
+ "chart"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "// Object with custom _toSvg method\n",
+ "const pie = {\n",
+ " values: [30, 20, 25, 25],\n",
+ " colors: ['#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4'],\n",
+ " _toSvg() {\n",
+ " const total = this.values.reduce((a, b) => a + b, 0);\n",
+ " let currentAngle = 0;\n",
+ " const paths = this.values.map((value, i) => {\n",
+ " const angle = (value / total) * 2 * Math.PI;\n",
+ " const x1 = 100 + 80 * Math.cos(currentAngle);\n",
+ " const y1 = 100 + 80 * Math.sin(currentAngle);\n",
+ " currentAngle += angle;\n",
+ " const x2 = 100 + 80 * Math.cos(currentAngle);\n",
+ " const y2 = 100 + 80 * Math.sin(currentAngle);\n",
+ " const largeArc = angle > Math.PI ? 1 : 0;\n",
+ " return ``;\n",
+ " }).join('');\n",
+ " return ``;\n",
+ " }\n",
+ "};\n",
+ "\n",
+ "pie"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Error Objects"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "new Error('This is an error object')"
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "new TypeError('Expected string but got number')"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "JavaScript",
+ "language": "javascript",
+ "name": "javascript"
+ },
+ "language_info": {
+ "file_extension": ".js",
+ "mimetype": "text/javascript",
+ "name": "javascript",
+ "version": "ES2021"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 4
+}
diff --git a/examples/web-worker-compute.ipynb b/examples/web-worker-compute.ipynb
new file mode 100644
index 0000000..7a05a27
--- /dev/null
+++ b/examples/web-worker-compute.ipynb
@@ -0,0 +1,241 @@
+{
+ "cells": [
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "# Heavy Computation in a Web Worker\n",
+ "\n",
+ "The JavaScript kernel runs inside a **Web Worker** \u2014 a background thread separate from the browser's main UI thread.\n",
+ "\n",
+ "This means CPU-intensive code does not freeze the browser. While a heavy cell executes, you can still scroll the page, interact with other UI elements, and queue up the next cell.\n",
+ "\n",
+ "This notebook runs four progressively heavier benchmarks, all off the main thread:\n",
+ "\n",
+ "- **Sieve of Eratosthenes** \u2014 find all primes up to 200,000,000\n",
+ "- **Monte Carlo \u03c0 estimation** \u2014 estimate \u03c0 with 200 million random samples\n",
+ "- **Large array sort** \u2014 sort 20 million random floats\n",
+ "- **Matrix multiplication** \u2014 multiply two 1024\u00d71024 matrices\n",
+ "\n",
+ "> **Try it:** start running the cells and try scrolling this page \u2014 it stays fully responsive throughout."
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Setup\n",
+ "\n",
+ "A shared `time()` helper measures each computation and collects results for a summary visualization at the end."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "// Shared results log \u2014 persists across all cells in this notebook\n",
+ "const results = [];\n",
+ "\n",
+ "// Runs fn(), logs elapsed time, and stores the result\n",
+ "function time(label, fn) {\n",
+ " const t0 = performance.now();\n",
+ " const value = fn();\n",
+ " const elapsed = +(performance.now() - t0).toFixed(1);\n",
+ " results.push({ label, elapsed });\n",
+ " console.log(`${label}: ${elapsed} ms`);\n",
+ " return value;\n",
+ "}"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Sieve of Eratosthenes\n",
+ "\n",
+ "A classic prime-finding algorithm: mark composite numbers by iterating over multiples of each prime found so far, then count what remains. Finding all primes up to 200 million requires marking hundreds of millions of entries in a typed array \u2014 a tight, cache-friendly inner loop with no pauses."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "function sieve(limit) {\n",
+ " const composite = new Uint8Array(limit + 1); // composite[i] = 1 means i is not prime\n",
+ " for (let i = 2; i * i <= limit; i++) {\n",
+ " if (!composite[i]) {\n",
+ " for (let j = i * i; j <= limit; j += i) {\n",
+ " composite[j] = 1;\n",
+ " }\n",
+ " }\n",
+ " }\n",
+ " let count = 0;\n",
+ " for (let i = 2; i <= limit; i++) {\n",
+ " if (!composite[i]) count++;\n",
+ " }\n",
+ " return count;\n",
+ "}\n",
+ "\n",
+ "const primeLimit = 200_000_000;\n",
+ "const primeCount = time(\n",
+ " `Sieve of Eratosthenes (up to ${primeLimit.toLocaleString()})`,\n",
+ " () => sieve(primeLimit)\n",
+ ");\n",
+ "console.log(`Found ${primeCount.toLocaleString()} primes`);\n",
+ "primeCount"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Monte Carlo \u03c0 Estimation\n",
+ "\n",
+ "Throw random darts at a unit square and count how many land inside the inscribed quarter-circle. The ratio converges to \u03c0/4. With 200 million samples the result is typically accurate to about 5 decimal places \u2014 and generating 400 million random numbers plus comparisons keeps the CPU busy for several seconds."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "function estimatePi(samples) {\n",
+ " let inside = 0;\n",
+ " for (let i = 0; i < samples; i++) {\n",
+ " const x = Math.random();\n",
+ " const y = Math.random();\n",
+ " if (x * x + y * y <= 1) inside++;\n",
+ " }\n",
+ " return (4 * inside) / samples;\n",
+ "}\n",
+ "\n",
+ "const piSamples = 200_000_000;\n",
+ "const piEstimate = time(\n",
+ " `Monte Carlo \u03c0 (${piSamples.toLocaleString()} samples)`,\n",
+ " () => estimatePi(piSamples)\n",
+ ");\n",
+ "console.log(`\u03c0 \u2248 ${piEstimate.toFixed(8)}`);\n",
+ "console.log(`error: ${Math.abs(piEstimate - Math.PI).toFixed(8)}`);\n",
+ "piEstimate"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Sorting 20 Million Random Floats\n",
+ "\n",
+ "Fill a `Float64Array` with random values, then sort it. JavaScript engines use TimSort (a hybrid merge/insertion sort), giving O(n log n) comparisons. For 20 million elements that is roughly 486 million comparison operations. `TypedArray.sort()` sorts numerically by default \u2014 no comparator needed."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "const sortSize = 20_000_000;\n",
+ "\n",
+ "const sortedArr = time(\n",
+ " `Sort ${sortSize.toLocaleString()} random floats`,\n",
+ " () => {\n",
+ " const arr = new Float64Array(sortSize);\n",
+ " for (let i = 0; i < sortSize; i++) arr[i] = Math.random();\n",
+ " arr.sort();\n",
+ " return arr;\n",
+ " }\n",
+ ");\n",
+ "\n",
+ "console.log(`min: ${sortedArr[0].toFixed(8)}`);\n",
+ "console.log(`max: ${sortedArr[sortedArr.length - 1].toFixed(8)}`);\n",
+ "sortedArr.length"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Matrix Multiplication (1024\u00d71024)\n",
+ "\n",
+ "Multiplying two 1024\u00d71024 matrices requires 1024\u00b3 \u2248 1.07 billion floating-point multiply-accumulate operations. The inner loop follows the i\u2013k\u2013j iteration order for better cache locality \u2014 reading `B` in sequential memory strides rather than the naive i\u2013j\u2013k column-jumping order."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "function matMul(a, b, n) {\n",
+ " const c = new Float64Array(n * n);\n",
+ " for (let i = 0; i < n; i++) {\n",
+ " for (let k = 0; k < n; k++) {\n",
+ " const aik = a[i * n + k];\n",
+ " for (let j = 0; j < n; j++) {\n",
+ " c[i * n + j] += aik * b[k * n + j];\n",
+ " }\n",
+ " }\n",
+ " }\n",
+ " return c;\n",
+ "}\n",
+ "\n",
+ "const matSize = 1024;\n",
+ "const matA = Float64Array.from({ length: matSize * matSize }, Math.random);\n",
+ "const matB = Float64Array.from({ length: matSize * matSize }, Math.random);\n",
+ "\n",
+ "const matC = time(\n",
+ " `Matrix multiply ${matSize}\u00d7${matSize}`,\n",
+ " () => matMul(matA, matB, matSize)\n",
+ ");\n",
+ "\n",
+ "console.log(`C[0,0] = ${matC[0].toFixed(6)}`);\n",
+ "matC.length"
+ ]
+ },
+ {
+ "cell_type": "markdown",
+ "metadata": {},
+ "source": [
+ "## Results Summary\n",
+ "\n",
+ "All four benchmarks ran entirely inside the Web Worker. The timings below are from your run \u2014 actual values vary by device and browser."
+ ]
+ },
+ {
+ "cell_type": "code",
+ "execution_count": null,
+ "metadata": {},
+ "outputs": [],
+ "source": [
+ "const maxElapsed = Math.max(...results.map(r => r.elapsed));\n",
+ "const rows = results.map(({ label, elapsed }) => {\n",
+ " const pct = (elapsed / maxElapsed * 90).toFixed(1);\n",
+ " const bar = ``;\n",
+ " return `| ${label} | ${bar} | ${elapsed} ms |
`;\n",
+ "}).join('');\n",
+ "\n",
+ "``"
+ ]
+ }
+ ],
+ "metadata": {
+ "kernelspec": {
+ "display_name": "JavaScript (Worker)",
+ "language": "javascript",
+ "name": "javascript-worker"
+ },
+ "language_info": {
+ "file_extension": ".js",
+ "mimetype": "text/javascript",
+ "name": "javascript",
+ "version": "ES2021"
+ }
+ },
+ "nbformat": 4,
+ "nbformat_minor": 4
+}
diff --git a/packages/javascript-kernel-extension/package.json b/packages/javascript-kernel-extension/package.json
index a4e10c2..3758c4c 100644
--- a/packages/javascript-kernel-extension/package.json
+++ b/packages/javascript-kernel-extension/package.json
@@ -39,12 +39,12 @@
"watch:src": "tsc -w"
},
"dependencies": {
- "@jupyterlab/application": "^4.5.0",
+ "@jupyterlab/application": "^4.5.5",
"@jupyterlite/javascript-kernel": "^0.4.0-alpha.0",
"@jupyterlite/services": "^0.7.0"
},
"devDependencies": {
- "@jupyterlab/builder": "^4.5.0",
+ "@jupyterlab/builder": "^4.5.5",
"npm-run-all2": "^7.0.1",
"rimraf": "~5.0.1",
"typescript": "~5.0.2"
diff --git a/packages/javascript-kernel-extension/src/index.ts b/packages/javascript-kernel-extension/src/index.ts
index dd526a2..0ceaf1f 100644
--- a/packages/javascript-kernel-extension/src/index.ts
+++ b/packages/javascript-kernel-extension/src/index.ts
@@ -11,45 +11,89 @@ import type { IKernel } from '@jupyterlite/services';
import { IKernelSpecs } from '@jupyterlite/services';
import { JavaScriptKernel } from '@jupyterlite/javascript-kernel';
+import type { RuntimeMode } from '@jupyterlite/javascript-kernel';
import jsLogo32 from '../style/icons/logo-32x32.png';
import jsLogo64 from '../style/icons/logo-64x64.png';
/**
- * A plugin to register the JavaScript kernel.
+ * Register a JavaScript kernelspec for a given runtime.
*/
-const kernel: JupyterFrontEndPlugin = {
- id: '@jupyterlite/javascript-kernel-extension:kernel',
- autoStart: true,
- requires: [IKernelSpecs],
- activate: (app: JupyterFrontEnd, kernelspecs: IKernelSpecs) => {
- kernelspecs.register({
+interface IRegisterKernelOptions {
+ name: string;
+ displayName: string;
+ runtime: RuntimeMode;
+}
+
+const registerKernel = (
+ kernelspecs: IKernelSpecs,
+ options: IRegisterKernelOptions
+) => {
+ const { name, displayName, runtime } = options;
+
+ kernelspecs.register({
+ spec: {
+ name,
+ display_name: displayName,
+ language: 'javascript',
+ argv: [],
spec: {
- name: 'javascript',
- display_name: 'JavaScript (Web Worker)',
- language: 'javascript',
argv: [],
- spec: {
- argv: [],
- env: {},
- display_name: 'JavaScript (Web Worker)',
- language: 'javascript',
- interrupt_mode: 'message',
- metadata: {}
- },
- resources: {
- 'logo-32x32': jsLogo32,
- 'logo-64x64': jsLogo64
+ env: {},
+ display_name: displayName,
+ language: 'javascript',
+ interrupt_mode: 'message',
+ metadata: {
+ runtime
}
},
- create: async (options: IKernel.IOptions): Promise => {
- return new JavaScriptKernel(options);
+ resources: {
+ 'logo-32x32': jsLogo32,
+ 'logo-64x64': jsLogo64
}
+ },
+ create: async (options: IKernel.IOptions): Promise => {
+ return new JavaScriptKernel({
+ ...options,
+ runtime
+ } as JavaScriptKernel.IOptions);
+ }
+ });
+};
+
+/**
+ * Plugin registering the iframe JavaScript kernel.
+ */
+const kernelIFrame: JupyterFrontEndPlugin = {
+ id: '@jupyterlite/javascript-kernel-extension:kernel-iframe',
+ autoStart: true,
+ requires: [IKernelSpecs],
+ activate: (app: JupyterFrontEnd, kernelspecs: IKernelSpecs) => {
+ registerKernel(kernelspecs, {
+ name: 'javascript',
+ displayName: 'JavaScript (IFrame)',
+ runtime: 'iframe'
+ });
+ }
+};
+
+/**
+ * Plugin registering the worker JavaScript kernel.
+ */
+const kernelWorker: JupyterFrontEndPlugin = {
+ id: '@jupyterlite/javascript-kernel-extension:kernel-worker',
+ autoStart: true,
+ requires: [IKernelSpecs],
+ activate: (app: JupyterFrontEnd, kernelspecs: IKernelSpecs) => {
+ registerKernel(kernelspecs, {
+ name: 'javascript-worker',
+ displayName: 'JavaScript (Web Worker)',
+ runtime: 'worker'
});
}
};
-const plugins: JupyterFrontEndPlugin[] = [kernel];
+const plugins: JupyterFrontEndPlugin[] = [kernelIFrame, kernelWorker];
export default plugins;
diff --git a/packages/javascript-kernel/package.json b/packages/javascript-kernel/package.json
index fc88ffe..b3a4b74 100644
--- a/packages/javascript-kernel/package.json
+++ b/packages/javascript-kernel/package.json
@@ -43,17 +43,19 @@
"watch": "tsc -b --watch"
},
"dependencies": {
- "@jupyterlab/coreutils": "^6.0.0",
+ "@jupyterlab/coreutils": "^6.5.5",
+ "@jupyterlab/nbformat": "^4.5.0",
"@jupyterlite/services": "^0.7.0",
+ "@lumino/coreutils": "^2.2.2",
+ "astring": "^1.9.0",
"comlink": "^4.3.1",
- "object-inspect": "^1.13.1"
+ "meriyah": "^4.3.9"
},
"devDependencies": {
"@babel/core": "^7.11.6",
"@babel/preset-env": "^7.12.1",
"@jupyterlab/testutils": "~4.5.0",
"@types/jest": "^26.0.10",
- "@types/object-inspect": "^1.8.4",
"jest": "^26.4.2",
"rimraf": "~5.0.1",
"ts-jest": "^26.3.0",
diff --git a/packages/javascript-kernel/src/comlink.worker.ts b/packages/javascript-kernel/src/comlink.worker.ts
deleted file mode 100644
index 6f37fa1..0000000
--- a/packages/javascript-kernel/src/comlink.worker.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-// Copyright (c) Jupyter Development Team.
-// Distributed under the terms of the Modified BSD License.
-
-/**
- * A WebWorker entrypoint that uses comlink to handle postMessage details
- */
-import { expose } from 'comlink';
-
-import { JavaScriptRemoteKernel } from './worker';
-
-const worker = new JavaScriptRemoteKernel();
-
-expose(worker);
diff --git a/packages/javascript-kernel/src/display.ts b/packages/javascript-kernel/src/display.ts
new file mode 100644
index 0000000..a1220ea
--- /dev/null
+++ b/packages/javascript-kernel/src/display.ts
@@ -0,0 +1,243 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import type { KernelMessage } from '@jupyterlab/services';
+import type { IMimeBundle } from '@jupyterlab/nbformat';
+
+/**
+ * Display request from display().
+ */
+export type IDisplayData = Omit<
+ KernelMessage.IDisplayDataMsg['content'],
+ 'data'
+> & {
+ data: IMimeBundle;
+};
+
+/**
+ * Callbacks for display operations.
+ */
+export interface IDisplayCallbacks {
+ onDisplay?: (data: IDisplayData) => void;
+ onClear?: (wait: boolean) => void;
+}
+
+/**
+ * Display helper class for rich output.
+ * Provides methods like html(), svg(), png(), etc.
+ */
+export class DisplayHelper {
+ /**
+ * Instantiate a new DisplayHelper.
+ *
+ * @param displayId - Optional display ID for update operations.
+ */
+ constructor(displayId?: string) {
+ this._displayId = displayId;
+ }
+
+ /**
+ * Set the callbacks for display operations.
+ *
+ * @param callbacks - The callbacks for display and clear operations.
+ */
+ setCallbacks(callbacks: IDisplayCallbacks): void {
+ this._displayCallback = callbacks.onDisplay;
+ this._clearCallback = callbacks.onClear;
+ }
+
+ /**
+ * Get the result if set via display methods.
+ *
+ * @returns The MIME bundle result, or undefined if not set.
+ */
+ getResult(): IMimeBundle | undefined {
+ return this._result;
+ }
+
+ /**
+ * Clear the result.
+ */
+ clearResult(): void {
+ this._result = undefined;
+ }
+
+ /**
+ * Create a new display with optional ID.
+ *
+ * @param id - Optional display ID for update operations.
+ * @returns A new DisplayHelper instance.
+ *
+ * @example
+ * display('my-id').html('...
')
+ */
+ display(id?: string): DisplayHelper {
+ const child = new DisplayHelper(id);
+ child.setCallbacks({
+ onDisplay: this._displayCallback,
+ onClear: this._clearCallback
+ });
+ return child;
+ }
+
+ /**
+ * Display HTML content.
+ *
+ * @param content - The HTML content to display.
+ * @param metadata - Optional metadata for the display.
+ */
+ html(content: string, metadata?: Record): void {
+ this._sendDisplay(
+ { 'text/html': content, 'text/plain': content },
+ metadata
+ );
+ }
+
+ /**
+ * Display SVG content.
+ *
+ * @param content - The SVG content to display.
+ * @param metadata - Optional metadata for the display.
+ */
+ svg(content: string, metadata?: Record): void {
+ this._sendDisplay(
+ {
+ 'image/svg+xml': content,
+ 'text/plain': '[SVG Image]'
+ },
+ metadata
+ );
+ }
+
+ /**
+ * Display PNG image (base64 encoded).
+ *
+ * @param base64Content - The base64-encoded PNG data.
+ * @param metadata - Optional metadata for the display.
+ */
+ png(base64Content: string, metadata?: Record): void {
+ this._sendDisplay(
+ {
+ 'image/png': base64Content,
+ 'text/plain': '[PNG Image]'
+ },
+ metadata
+ );
+ }
+
+ /**
+ * Display JPEG image (base64 encoded).
+ *
+ * @param base64Content - The base64-encoded JPEG data.
+ * @param metadata - Optional metadata for the display.
+ */
+ jpeg(base64Content: string, metadata?: Record): void {
+ this._sendDisplay(
+ {
+ 'image/jpeg': base64Content,
+ 'text/plain': '[JPEG Image]'
+ },
+ metadata
+ );
+ }
+
+ /**
+ * Display plain text.
+ *
+ * @param content - The text content to display.
+ * @param metadata - Optional metadata for the display.
+ */
+ text(content: string, metadata?: Record): void {
+ this._sendDisplay({ 'text/plain': content }, metadata);
+ }
+
+ /**
+ * Display Markdown content.
+ *
+ * @param content - The Markdown content to display.
+ * @param metadata - Optional metadata for the display.
+ */
+ markdown(content: string, metadata?: Record): void {
+ this._sendDisplay(
+ { 'text/markdown': content, 'text/plain': content },
+ metadata
+ );
+ }
+
+ /**
+ * Display LaTeX content.
+ *
+ * @param content - The LaTeX content to display.
+ * @param metadata - Optional metadata for the display.
+ */
+ latex(content: string, metadata?: Record): void {
+ this._sendDisplay(
+ { 'text/latex': content, 'text/plain': content },
+ metadata
+ );
+ }
+
+ /**
+ * Display JSON content.
+ *
+ * @param content - The JSON content to display.
+ * @param metadata - Optional metadata for the display.
+ */
+ json(content: any, metadata?: Record): void {
+ this._sendDisplay(
+ {
+ 'application/json': content,
+ 'text/plain': JSON.stringify(content, null, 2)
+ },
+ metadata
+ );
+ }
+
+ /**
+ * Display with custom MIME bundle.
+ *
+ * @param mimeBundle - The MIME bundle to display.
+ * @param metadata - Optional metadata for the display.
+ */
+ mime(mimeBundle: IMimeBundle, metadata?: Record): void {
+ this._sendDisplay(mimeBundle, metadata);
+ }
+
+ /**
+ * Clear the current output.
+ *
+ * @param options - Clear options.
+ * @param options.wait - If true, wait for new output before clearing.
+ */
+ clear(options: { wait?: boolean } = {}): void {
+ if (this._clearCallback) {
+ this._clearCallback(options.wait ?? false);
+ }
+ }
+
+ /**
+ * Send display data.
+ */
+ private _sendDisplay(
+ data: IMimeBundle,
+ metadata?: Record
+ ): void {
+ const displayData: IDisplayData = {
+ data,
+ metadata: metadata ?? {},
+ transient: this._displayId ? { display_id: this._displayId } : undefined
+ };
+
+ if (this._displayCallback) {
+ this._displayCallback(displayData);
+ } else {
+ // Store as result for synchronous return
+ this._result = data;
+ }
+ }
+
+ private _displayCallback?: (data: IDisplayData) => void;
+ private _clearCallback?: (wait: boolean) => void;
+ private _displayId?: string;
+ private _result?: IMimeBundle;
+}
diff --git a/packages/javascript-kernel/src/errors.ts b/packages/javascript-kernel/src/errors.ts
new file mode 100644
index 0000000..4670bdd
--- /dev/null
+++ b/packages/javascript-kernel/src/errors.ts
@@ -0,0 +1,55 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+type ErrorLike = {
+ name?: unknown;
+ message?: unknown;
+ stack?: unknown;
+};
+
+/**
+ * Normalize unknown thrown values into Error instances.
+ *
+ * Supports cross-realm Error objects (for example iframe-thrown errors)
+ * by preserving their name/message/stack fields even when `instanceof Error`
+ * is false in the current realm.
+ */
+export function normalizeError(error: unknown, fallbackName = 'Error'): Error {
+ if (error instanceof Error) {
+ return error;
+ }
+
+ if (isErrorLike(error)) {
+ const normalized = new Error(
+ typeof error.message === 'string' ? error.message : safeToString(error)
+ );
+ normalized.name =
+ typeof error.name === 'string' && error.name ? error.name : fallbackName;
+
+ if (typeof error.stack === 'string' && error.stack.length > 0) {
+ normalized.stack = error.stack;
+ }
+
+ return normalized;
+ }
+
+ const normalized = new Error(safeToString(error));
+ normalized.name = fallbackName;
+ return normalized;
+}
+
+function isErrorLike(error: unknown): error is ErrorLike {
+ return (
+ typeof error === 'object' &&
+ error !== null &&
+ ('name' in error || 'message' in error || 'stack' in error)
+ );
+}
+
+function safeToString(value: unknown): string {
+ try {
+ return String(value);
+ } catch {
+ return 'Unknown error';
+ }
+}
diff --git a/packages/javascript-kernel/src/executor.ts b/packages/javascript-kernel/src/executor.ts
new file mode 100644
index 0000000..3330b4e
--- /dev/null
+++ b/packages/javascript-kernel/src/executor.ts
@@ -0,0 +1,1925 @@
+// Copyright (c) Jupyter Development Team.
+// Distributed under the terms of the Modified BSD License.
+
+import type { KernelMessage } from '@jupyterlab/services';
+
+import { parseScript } from 'meriyah';
+import { generate } from 'astring';
+
+import type { IMimeBundle } from '@jupyterlab/nbformat';
+
+export { IDisplayData, IDisplayCallbacks, DisplayHelper } from './display';
+
+/**
+ * Configuration for magic imports.
+ */
+export interface IMagicImportsConfig {
+ enabled: boolean;
+ baseUrl: string;
+ enableAutoNpm: boolean;
+}
+
+/**
+ * Result of making code async.
+ */
+export interface IAsyncCodeResult {
+ asyncFunction: () => Promise;
+ withReturn: boolean;
+}
+
+/**
+ * Information about an extracted import.
+ */
+export interface IImportInfo {
+ /** The original import source (e.g., 'canvas-confetti') */
+ source: string;
+ /** The transformed URL (e.g., 'https://cdn.jsdelivr.net/npm/canvas-confetti/+esm') */
+ url: string;
+ /** The local variable name for default import */
+ defaultImport?: string;
+ /** The local variable name for namespace import */
+ namespaceImport?: string;
+ /** Named imports: { importedName: localName } */
+ namedImports: Record;
+}
+
+type JSCallable = (...args: any[]) => any;
+
+/**
+ * Result of code completion.
+ */
+export interface ICompletionResult {
+ matches: string[];
+ cursorStart: number;
+ cursorEnd?: number;
+ status?: string;
+}
+
+/**
+ * Result of code completeness check.
+ */
+export type IIsCompleteResult =
+ | KernelMessage.IIsCompleteReplyIncomplete
+ | KernelMessage.IIsCompleteReplyOther;
+
+/**
+ * Result of code inspection.
+ */
+export type IInspectResult = KernelMessage.IInspectReply;
+
+/**
+ * Registry for tracking code declarations across cells.
+ * Allows deduplication - later definitions override earlier ones.
+ */
+export interface ICodeRegistry {
+ /** Function declarations by name (setup, draw, etc.) */
+ functions: Map;
+ /** Variable declarations by name */
+ variables: Map;
+ /** Class declarations by name */
+ classes: Map;
+ /** Other top-level statements (expressions, etc.) in execution order */
+ statements: any[];
+}
+
+/**
+ * Configuration for the JavaScript executor.
+ */
+export class ExecutorConfig {
+ /**
+ * Get the magic imports configuration.
+ */
+ get magicImports(): IMagicImportsConfig {
+ return this._magicImports;
+ }
+
+ /**
+ * Set the magic imports configuration.
+ */
+ set magicImports(value: IMagicImportsConfig) {
+ this._magicImports = value;
+ }
+
+ private _magicImports: IMagicImportsConfig = {
+ enabled: true,
+ baseUrl: 'https://cdn.jsdelivr.net/',
+ enableAutoNpm: true
+ };
+}
+
+/**
+ * JavaScript code executor with advanced features.
+ */
+export class JavaScriptExecutor {
+ /**
+ * Instantiate a new JavaScriptExecutor.
+ *
+ * @param globalScope - The global scope (globalThis) for code execution.
+ * @param config - Optional executor configuration.
+ */
+ constructor(globalScope: Record, config?: ExecutorConfig) {
+ this._globalScope = globalScope;
+ this._config = config || new ExecutorConfig();
+ }
+
+ /**
+ * Convert user code to an async function.
+ *
+ * @param code - The user code to convert.
+ * @returns The async function and whether it has a return value.
+ */
+ makeAsyncFromCode(code: string): IAsyncCodeResult {
+ if (code.length === 0) {
+ return {
+ asyncFunction: async () => {},
+ withReturn: false
+ };
+ }
+
+ const ast = parseScript(code, {
+ ranges: true,
+ module: true
+ });
+
+ // Add top-level variables to global scope
+ let codeAddToGlobalScope = this._addToGlobalScope(ast);
+
+ // Handle last statement / add return if needed
+ const { withReturn, modifiedUserCode, extraReturnCode } =
+ this._handleLastStatement(code, ast);
+ let finalCode = modifiedUserCode;
+
+ // Handle import statements
+ const importResult = this._rewriteImportStatements(finalCode, ast);
+ finalCode = importResult.modifiedUserCode;
+ codeAddToGlobalScope += importResult.codeAddToGlobalScope;
+
+ const combinedCode = `
+ ${finalCode}
+ ${codeAddToGlobalScope}
+ ${extraReturnCode}
+ `;
+
+ const asyncFunctionFactory = this._createScopedFunction(`
+ return async function() {
+ ${combinedCode}
+ };
+ `) as () => () => Promise;
+ const asyncFunction = asyncFunctionFactory.call(this._globalScope);
+
+ return {
+ asyncFunction,
+ withReturn
+ };
+ }
+
+ /**
+ * Extract import information from code without executing it.
+ * Used to track imports for sketch generation.
+ *
+ * @param code - The code to analyze for imports.
+ * @returns Array of import information objects.
+ */
+ extractImports(code: string): IImportInfo[] {
+ if (code.length === 0) {
+ return [];
+ }
+
+ try {
+ const ast = parseScript(code, {
+ ranges: true,
+ module: true
+ });
+
+ const imports: IImportInfo[] = [];
+
+ for (const node of ast.body) {
+ if (node.type === 'ImportDeclaration') {
+ const source = String(node.source.value);
+ const url = this._transformImportSource(source);
+
+ const importInfo: IImportInfo = {
+ source,
+ url,
+ namedImports: {}
+ };
+
+ for (const specifier of node.specifiers) {
+ if (specifier.type === 'ImportDefaultSpecifier') {
+ importInfo.defaultImport = specifier.local.name;
+ } else if (specifier.type === 'ImportNamespaceSpecifier') {
+ importInfo.namespaceImport = specifier.local.name;
+ } else if (specifier.type === 'ImportSpecifier') {
+ if (specifier.imported.name === 'default') {
+ importInfo.defaultImport = specifier.local.name;
+ } else {
+ importInfo.namedImports[specifier.imported.name] =
+ specifier.local.name;
+ }
+ }
+ }
+
+ imports.push(importInfo);
+ }
+ }
+
+ return imports;
+ } catch {
+ return [];
+ }
+ }
+
+ /**
+ * Generate async JavaScript code to load imports and assign them to globalThis.
+ * This is used when generating the sketch iframe.
+ *
+ * @param imports - The import information objects.
+ * @returns Generated JavaScript code string.
+ */
+ generateImportCode(imports: IImportInfo[]): string {
+ if (imports.length === 0) {
+ return '';
+ }
+
+ const lines: string[] = [];
+
+ for (const imp of imports) {
+ const importCall = `import(${JSON.stringify(imp.url)})`;
+
+ if (imp.defaultImport) {
+ lines.push(
+ `const { default: ${imp.defaultImport} } = await ${importCall};`
+ );
+ lines.push(
+ `globalThis["${imp.defaultImport}"] = ${imp.defaultImport};`
+ );
+ }
+
+ if (imp.namespaceImport) {
+ lines.push(`const ${imp.namespaceImport} = await ${importCall};`);
+ lines.push(
+ `globalThis["${imp.namespaceImport}"] = ${imp.namespaceImport};`
+ );
+ }
+
+ const namedKeys = Object.keys(imp.namedImports);
+ if (namedKeys.length > 0) {
+ const destructure = namedKeys
+ .map(k =>
+ k === imp.namedImports[k] ? k : `${k}: ${imp.namedImports[k]}`
+ )
+ .join(', ');
+ lines.push(`const { ${destructure} } = await ${importCall};`);
+ for (const importedName of namedKeys) {
+ const localName = imp.namedImports[importedName];
+ lines.push(`globalThis["${localName}"] = ${localName};`);
+ }
+ }
+
+ // Side-effect only import (no specifiers)
+ if (
+ !imp.defaultImport &&
+ !imp.namespaceImport &&
+ Object.keys(imp.namedImports).length === 0
+ ) {
+ lines.push(`await ${importCall};`);
+ }
+ }
+
+ return lines.join('\n');
+ }
+
+ /**
+ * Create a new empty code registry.
+ */
+ createCodeRegistry(): ICodeRegistry {
+ return {
+ functions: new Map(),
+ variables: new Map(),
+ classes: new Map(),
+ statements: []
+ };
+ }
+
+ /**
+ * Register code from executed cells into the registry.
+ * Later definitions of the same name will override earlier ones.
+ * Import declarations are skipped (handled separately).
+ *
+ * @param code - The code to register.
+ * @param registry - The registry to add declarations to.
+ */
+ registerCode(code: string, registry: ICodeRegistry): void {
+ if (code.trim().length === 0) {
+ return;
+ }
+
+ try {
+ const ast = parseScript(code, {
+ ranges: true,
+ module: true
+ });
+
+ for (const node of ast.body) {
+ switch (node.type) {
+ case 'FunctionDeclaration':
+ // Store function by name - later definitions override
+ if (node.id && node.id.name) {
+ registry.functions.set(node.id.name, node);
+ }
+ break;
+
+ case 'ClassDeclaration':
+ // Store class by name - later definitions override
+ if (node.id && node.id.name) {
+ registry.classes.set(node.id.name, node);
+ }
+ break;
+
+ case 'VariableDeclaration':
+ // For variable declarations, extract each declarator
+ for (const declarator of node.declarations) {
+ if (declarator.id.type === 'Identifier') {
+ // Store the whole declaration node with just this declarator
+ const singleDecl = {
+ ...node,
+ declarations: [declarator]
+ };
+ registry.variables.set(declarator.id.name, singleDecl);
+ } else if (declarator.id.type === 'ObjectPattern') {
+ // Handle destructuring: const { a, b } = obj
+ for (const prop of declarator.id.properties) {
+ if (
+ prop.type === 'Property' &&
+ prop.key.type === 'Identifier'
+ ) {
+ const name =
+ prop.value?.type === 'Identifier'
+ ? prop.value.name
+ : prop.key.name;
+ registry.variables.set(name, {
+ ...node,
+ declarations: [declarator],
+ _destructuredName: name
+ });
+ }
+ }
+ } else if (declarator.id.type === 'ArrayPattern') {
+ // Handle array destructuring: const [a, b] = arr
+ for (const element of declarator.id.elements) {
+ if (element && element.type === 'Identifier') {
+ registry.variables.set(element.name, {
+ ...node,
+ declarations: [declarator],
+ _destructuredName: element.name
+ });
+ }
+ }
+ }
+ }
+ break;
+
+ case 'ImportDeclaration':
+ // Skip imports - handled separately via extractImports
+ break;
+
+ case 'ExpressionStatement':
+ registry.statements.push(node);
+ break;
+
+ default:
+ // Other statements (if, for, while, etc.) - keep in order
+ registry.statements.push(node);
+ break;
+ }
+ }
+ } catch {
+ // If parsing fails, we can't register the code
+ }
+ }
+
+ /**
+ * Generate code from the registry.
+ * Produces clean, deduplicated code for regeneration scenarios.
+ * Includes globalThis assignments so declarations are accessible globally.
+ *
+ * @param registry - The registry to generate code from.
+ * @returns Generated JavaScript code string.
+ */
+ generateCodeFromRegistry(registry: ICodeRegistry): string {
+ const programBody: any[] = [];
+ const globalAssignments: string[] = [];
+
+ // Add variables first (they might be used by functions)
+ const seenDestructuringDecls = new Set();
+ for (const [name, node] of registry.variables) {
+ // For destructuring, only add once per actual declaration
+ if (node._destructuredName) {
+ const declKey = generate(node.declarations[0]);
+ if (seenDestructuringDecls.has(declKey)) {
+ continue;
+ }
+ seenDestructuringDecls.add(declKey);
+ // Remove the marker before generating
+ const cleanNode = { ...node };
+ delete cleanNode._destructuredName;
+ programBody.push(cleanNode);
+ } else {
+ programBody.push(node);
+ }
+ globalAssignments.push(`globalThis["${name}"] = ${name};`);
+ }
+
+ // Add classes
+ for (const [name, node] of registry.classes) {
+ programBody.push(node);
+ globalAssignments.push(`globalThis["${name}"] = ${name};`);
+ }
+
+ // Add functions
+ for (const [name, node] of registry.functions) {
+ programBody.push(node);
+ globalAssignments.push(`globalThis["${name}"] = ${name};`);
+ }
+
+ // Add other statements in order
+ for (const node of registry.statements) {
+ programBody.push(node);
+ }
+
+ // Create a program AST and generate code
+ const program = {
+ type: 'Program',
+ body: programBody,
+ sourceType: 'script'
+ };
+
+ // Generate the code and append globalThis assignments
+ const generatedCode = generate(program);
+
+ if (globalAssignments.length > 0) {
+ return generatedCode + '\n' + globalAssignments.join('\n');
+ }
+
+ return generatedCode;
+ }
+
+ /**
+ * Get MIME bundle for a value.
+ * Supports custom output methods:
+ * - _toHtml() for text/html
+ * - _toSvg() for image/svg+xml
+ * - _toPng() for image/png (base64)
+ * - _toJpeg() for image/jpeg (base64)
+ * - _toMime() for custom MIME bundle
+ * - inspect() for text/plain (Node.js style)
+ *
+ * @param value - The value to convert to a MIME bundle.
+ * @returns The MIME bundle representation of the value.
+ */
+ getMimeBundle(value: any): IMimeBundle {
+ // Handle null and undefined
+ if (value === null) {
+ return { 'text/plain': 'null' };
+ }
+ if (value === undefined) {
+ return { 'text/plain': 'undefined' };
+ }
+
+ // Check for custom MIME output methods
+ if (typeof value === 'object' && value !== null) {
+ const customMime = this._getCustomMimeBundle(value);
+ if (customMime) {
+ return customMime;
+ }
+ }
+
+ // Handle primitives
+ if (typeof value === 'string') {
+ // Check if it looks like HTML (must start with a valid tag: ,
,
+ // , ,
, etc.). Rejects non-HTML like "".
+ const trimmed = value.trim();
+ if (
+ /^<(?:[a-zA-Z][a-zA-Z0-9-]*[\s/>]|!(?:DOCTYPE|--))/.test(trimmed) &&
+ trimmed.endsWith('>')
+ ) {
+ return {
+ 'text/html': value,
+ 'text/plain': value
+ };
+ }
+ return { 'text/plain': `'${value}'` };
+ }
+ if (typeof value === 'number' || typeof value === 'boolean') {
+ return { 'text/plain': String(value) };
+ }
+
+ // Handle Symbol
+ if (typeof value === 'symbol') {
+ return { 'text/plain': value.toString() };
+ }
+
+ // Handle BigInt
+ if (typeof value === 'bigint') {
+ return { 'text/plain': `${value.toString()}n` };
+ }
+
+ // Handle functions
+ if (typeof value === 'function') {
+ const funcString = value.toString();
+ const name = value.name || 'anonymous';
+ return {
+ 'text/plain': `[Function: ${name}]`,
+ 'text/html': `${this._escapeHtml(funcString)}
`
+ };
+ }
+
+ // Handle Error objects
+ if (this._isInstanceOfRealm(value, 'Error')) {
+ const errorValue = value as Error;
+ return {
+ 'text/plain': errorValue.stack || errorValue.toString(),
+ 'application/json': {
+ name: errorValue.name,
+ message: errorValue.message,
+ stack: errorValue.stack
+ }
+ };
+ }
+
+ // Handle Date objects
+ if (this._isInstanceOfRealm(value, 'Date')) {
+ const dateValue = value as Date;
+ return {
+ 'text/plain': dateValue.toISOString(),
+ 'application/json': dateValue.toISOString()
+ };
+ }
+
+ // Handle RegExp objects
+ if (this._isInstanceOfRealm(value, 'RegExp')) {
+ return { 'text/plain': (value as RegExp).toString() };
+ }
+
+ // Handle Map
+ if (this._isInstanceOfRealm(value, 'Map')) {
+ const mapValue = value as Map;
+ const entries = Array.from(mapValue.entries());
+ try {
+ return {
+ 'text/plain': `Map(${mapValue.size}) { ${entries.map(([k, v]) => `${String(k)} => ${String(v)}`).join(', ')} }`,
+ 'application/json': Object.fromEntries(entries)
+ };
+ } catch {
+ return { 'text/plain': `Map(${mapValue.size})` };
+ }
+ }
+
+ // Handle Set
+ if (this._isInstanceOfRealm(value, 'Set')) {
+ const setValue = value as Set;
+ const items = Array.from(setValue);
+ try {
+ return {
+ 'text/plain': `Set(${setValue.size}) { ${items.map(v => String(v)).join(', ')} }`,
+ 'application/json': items
+ };
+ } catch {
+ return { 'text/plain': `Set(${setValue.size})` };
+ }
+ }
+
+ // Handle DOM elements (Canvas, HTMLElement, etc.)
+ if (this._isDOMElement(value)) {
+ return this._getDOMElementMimeBundle(value);
+ }
+
+ // Handle arrays
+ if (Array.isArray(value)) {
+ try {
+ const preview = this._formatArrayPreview(value);
+ return {
+ 'application/json': value,
+ 'text/plain': preview
+ };
+ } catch {
+ return { 'text/plain': `Array(${value.length})` };
+ }
+ }
+
+ // Handle typed arrays
+ if (ArrayBuffer.isView(value)) {
+ const typedArray = value as unknown as { length: number };
+ return {
+ 'text/plain': `${value.constructor.name}(${typedArray.length})`
+ };
+ }
+
+ // Handle Promise (show as pending)
+ if (this._isInstanceOfRealm(value, 'Promise')) {
+ return { 'text/plain': 'Promise { }' };
+ }
+
+ // Handle generic objects
+ if (typeof value === 'object') {
+ // Check if it's already a mime bundle (has data with MIME-type keys)
+ if (value.data && typeof value.data === 'object') {
+ const dataKeys = Object.keys(value.data);
+ const hasMimeKeys = dataKeys.some(key => key.includes('/'));
+ if (hasMimeKeys) {
+ return value.data;
+ }
+ }
+
+ try {
+ const preview = this._formatObjectPreview(value);
+ return {
+ 'application/json': value,
+ 'text/plain': preview
+ };
+ } catch {
+ // Object might have circular references or be non-serializable
+ return { 'text/plain': this._formatNonSerializableObject(value) };
+ }
+ }
+
+ // Fallback
+ return { 'text/plain': String(value) };
+ }
+
+ /**
+ * Complete code at cursor position.
+ *
+ * @param codeLine - The line of code to complete.
+ * @param globalScope - The global scope for variable lookup.
+ * @returns The completion result with matches and cursor position.
+ */
+ completeLine(
+ codeLine: string,
+ globalScope: any = this._globalScope
+ ): ICompletionResult {
+ // Remove unwanted left part
+ const stopChars = ' {}()=+-*/%&|^~<>,:;!?@#';
+ let codeBegin = 0;
+ for (let i = codeLine.length - 1; i >= 0; i--) {
+ if (stopChars.includes(codeLine[i])) {
+ codeBegin = i + 1;
+ break;
+ }
+ }
+
+ const pseudoExpression = codeLine.substring(codeBegin);
+
+ // Find part right of dot/bracket
+ const expStopChars = '.]';
+ let splitPos = pseudoExpression.length;
+ let found = false;
+
+ for (let i = splitPos - 1; i >= 0; i--) {
+ if (expStopChars.includes(pseudoExpression[i])) {
+ splitPos = i;
+ found = true;
+ break;
+ }
+ }
+
+ let rootObjectStr = '';
+ let toMatch = pseudoExpression;
+ let cursorStart = codeBegin;
+
+ if (found) {
+ rootObjectStr = pseudoExpression.substring(0, splitPos);
+ toMatch = pseudoExpression.substring(splitPos + 1);
+ cursorStart += splitPos + 1;
+ }
+
+ // Find root object
+ let rootObject = globalScope;
+ if (rootObjectStr !== '') {
+ try {
+ const evalFunc = this._createScopedFunction(
+ 'scope',
+ `with(scope) { return ${rootObjectStr}; }`
+ ) as (scope: any) => any;
+ rootObject = evalFunc(globalScope);
+ } catch {
+ return {
+ matches: [],
+ cursorStart,
+ status: 'error'
+ };
+ }
+ }
+
+ // Collect all properties including from prototype chain
+ const matches = this._getAllProperties(rootObject, toMatch);
+
+ return {
+ matches,
+ cursorStart
+ };
+ }
+
+ /**
+ * Complete request with multi-line support.
+ *
+ * @param code - The full code content.
+ * @param cursorPos - The cursor position in the code.
+ * @returns The completion result with matches and cursor positions.
+ */
+ completeRequest(code: string, cursorPos: number): ICompletionResult {
+ const lines = code.split('\n');
+
+ // Find line the cursor is on
+ let lineIndex = 0;
+ let cursorPosInLine = 0;
+ let lineBegin = 0;
+
+ for (let i = 0; i < lines.length; i++) {
+ if (cursorPos >= lineBegin && cursorPos <= lineBegin + lines[i].length) {
+ lineIndex = i;
+ cursorPosInLine = cursorPos - lineBegin;
+ break;
+ }
+ lineBegin += lines[i].length + 1; // +1 for \n
+ }
+
+ const codeLine = lines[lineIndex];
+
+ const codePrefix = codeLine.slice(0, cursorPosInLine);
+ const lineRes = this.completeLine(codePrefix);
+ const matches = lineRes.matches;
+ const inLineCursorStart = lineRes.cursorStart;
+ const tail = codeLine.slice(cursorPosInLine);
+ const cursorTail = tail.match(/^[\w$]*/)?.[0] ?? '';
+
+ return {
+ matches,
+ cursorStart: lineBegin + inLineCursorStart,
+ cursorEnd: cursorPos + cursorTail.length,
+ status: lineRes.status || 'ok'
+ };
+ }
+
+ /**
+ * Clean stack trace to remove internal frames.
+ *
+ * @param error - The error with stack trace to clean.
+ * @returns The cleaned stack trace string.
+ */
+ cleanStackTrace(error: Error): string {
+ const errStackStr = error.stack || '';
+ const errStackLines = errStackStr.split('\n');
+ const usedLines: string[] = [];
+
+ for (const line of errStackLines) {
+ // Stop at internal implementation details
+ if (
+ line.includes('makeAsyncFromCode') ||
+ line.includes('new Function') ||
+ line.includes('asyncFunction')
+ ) {
+ break;
+ }
+ usedLines.push(line);
+ }
+
+ return usedLines.join('\n');
+ }
+
+ /**
+ * Check if code is syntactically complete.
+ * Used for multi-line input in console-style interfaces.
+ *
+ * @param code - The code to check.
+ * @returns The completeness status and suggested indentation.
+ */
+ isComplete(code: string): IIsCompleteResult {
+ if (code.trim().length === 0) {
+ return { status: 'complete' };
+ }
+
+ try {
+ parseScript(code, {
+ ranges: true,
+ module: true
+ });
+ return { status: 'complete' };
+ } catch (e: any) {
+ const message = e.message || '';
+
+ // Common patterns indicating incomplete code
+ const incompletePatterns = [
+ /unexpected end of input/i,
+ /unterminated string/i,
+ /unterminated template/i,
+ /unexpected token.*eof/i,
+ /expected.*but.*end/i
+ ];
+
+ for (const pattern of incompletePatterns) {
+ if (pattern.test(message)) {
+ // Determine indentation for next line
+ const lines = code.split('\n');
+ const lastLine = lines[lines.length - 1];
+ const currentIndent = lastLine.match(/^(\s*)/)?.[1] || '';
+
+ // Add more indent if we're opening a block
+ const opensBlock = /[{([]$/.test(lastLine.trim());
+ const indent = opensBlock ? currentIndent + ' ' : currentIndent;
+
+ return { status: 'incomplete', indent };
+ }
+ }
+
+ // Syntax error that's not about incompleteness
+ return { status: 'invalid' };
+ }
+ }
+
+ /**
+ * Inspect an object at the cursor position.
+ * Returns documentation/type information for tooltips.
+ *
+ * @param code - The code containing the expression to inspect.
+ * @param cursorPos - The cursor position in the code.
+ * @param detailLevel - The level of detail (0 for basic, higher for more).
+ * @returns The inspection result with documentation data.
+ */
+ inspect(
+ code: string,
+ cursorPos: number,
+ detailLevel: KernelMessage.IInspectRequestMsg['content']['detail_level'] = 0
+ ): IInspectResult {
+ // Extract the word/expression at cursor position
+ const expression = this._extractExpressionAtCursor(code, cursorPos);
+
+ if (!expression) {
+ return {
+ status: 'ok',
+ found: false,
+ data: {},
+ metadata: {}
+ };
+ }
+
+ try {
+ // Try to evaluate the expression in the global scope
+ const evalFunc = this._createScopedFunction(
+ 'scope',
+ `with(scope) { return ${expression}; }`
+ ) as (scope: any) => any;
+ const value = evalFunc(this._globalScope);
+
+ // Build inspection data
+ const inspectionData = this._buildInspectionData(
+ expression,
+ value,
+ detailLevel
+ );
+
+ return {
+ status: 'ok',
+ found: true,
+ data: inspectionData,
+ metadata: {}
+ };
+ } catch {
+ // Try to provide info even if we can't evaluate
+ return this.inspectBuiltin(expression, detailLevel);
+ }
+ }
+
+ /**
+ * Provide inspection info for built-in objects.
+ * First tries runtime lookup, then falls back to predefined docs.
+ *
+ * @param expression - The expression to look up.
+ * @param detailLevel - The level of detail requested.
+ * @returns The inspection result.
+ */
+ protected inspectBuiltin(
+ expression: string,
+ detailLevel: number
+ ): IInspectResult {
+ // First, try to find the expression in the global scope at runtime
+ const runtimeResult = this._inspectAtRuntime(expression, detailLevel);
+ if (runtimeResult.found) {
+ return runtimeResult;
+ }
+
+ // Fall back to predefined documentation
+ const doc = this.getBuiltinDocumentation(expression);
+ if (doc) {
+ return {
+ status: 'ok',
+ found: true,
+ data: {
+ 'text/plain': `${expression}: ${doc}`,
+ 'text/markdown': `**${expression}**\n\n${doc}`
+ },
+ metadata: {}
+ };
+ }
+
+ // Try to find similar names in global scope for suggestions
+ const suggestions = this._findSimilarNames(expression);
+ if (suggestions.length > 0) {
+ return {
+ status: 'ok',
+ found: true,
+ data: {
+ 'text/plain': `'${expression}' not found. Did you mean: ${suggestions.join(', ')}?`,
+ 'text/markdown': `\`${expression}\` not found.\n\n**Did you mean:**\n${suggestions.map(s => `- \`${s}\``).join('\n')}`
+ },
+ metadata: {}
+ };
+ }
+
+ return {
+ status: 'ok',
+ found: false,
+ data: {},
+ metadata: {}
+ };
+ }
+
+ /**
+ * Get predefined documentation for built-in JavaScript objects.
+ * Subclasses can override this to add domain-specific documentation.
+ *
+ * @param expression - The expression to get documentation for.
+ * @returns The documentation string, or null if not found.
+ */
+ protected getBuiltinDocumentation(expression: string): string | null {
+ // Common JavaScript built-ins documentation
+ const builtins: Record = {
+ console:
+ 'The console object provides access to the browser debugging console.',
+ Math: 'The Math object provides mathematical constants and functions.',
+ JSON: 'The JSON object provides methods for parsing and stringifying JSON.',
+ Array:
+ 'The Array object is used to store multiple values in a single variable.',
+ Object: "The Object class represents one of JavaScript's data types.",
+ String:
+ 'The String object is used to represent and manipulate a sequence of characters.',
+ Number: 'The Number object is a wrapper object for numeric values.',
+ Date: 'The Date object represents a single moment in time.',
+ Promise:
+ 'The Promise object represents the eventual completion of an async operation.',
+ Map: 'The Map object holds key-value pairs and remembers the original insertion order.',
+ Set: 'The Set object lets you store unique values of any type.'
+ };
+
+ return builtins[expression] ?? null;
+ }
+
+ /**
+ * Add code to export top-level variables to global scope.
+ */
+ private _addToGlobalThisCode(key: string, identifier = key): string {
+ // Keep declarations on both globalThis and this for compatibility with
+ // different runtime invocation paths.
+ return `globalThis["${key}"] = this["${key}"] = ${identifier};`;
+ }
+
+ /**
+ * Create a function using the runtime realm's Function constructor.
+ */
+ private _createScopedFunction(...args: string[]): JSCallable {
+ const scopeFunction = this._globalScope.Function;
+ const functionConstructor =
+ typeof scopeFunction === 'function'
+ ? (scopeFunction as FunctionConstructor)
+ : Function;
+ return functionConstructor(...args) as JSCallable;
+ }
+
+ /**
+ * Replace a section of code with new code.
+ */
+ private _replaceCode(
+ code: string,
+ start: number,
+ end: number,
+ newCode: string
+ ): string {
+ return code.substring(0, start) + newCode + code.substring(end);
+ }
+
+ /**
+ * Add top-level variables to global scope.
+ */
+ private _addToGlobalScope(ast: any): string {
+ const extraCode: string[] = [];
+
+ for (const node of ast.body) {
+ if (node.type === 'FunctionDeclaration') {
+ const name = node.id.name;
+ extraCode.push(this._addToGlobalThisCode(name));
+ } else if (node.type === 'ClassDeclaration') {
+ const name = node.id.name;
+ extraCode.push(this._addToGlobalThisCode(name));
+ } else if (node.type === 'VariableDeclaration') {
+ const declarations = node.declarations;
+
+ for (const declaration of declarations) {
+ const identifiers: string[] = [];
+ this._collectDeclaredIdentifiers(declaration.id, identifiers);
+ for (const name of identifiers) {
+ extraCode.push(this._addToGlobalThisCode(name));
+ }
+ }
+ }
+ }
+
+ return extraCode.join('\n');
+ }
+
+ /**
+ * Collect identifiers from a declaration pattern.
+ */
+ private _collectDeclaredIdentifiers(
+ pattern: any,
+ identifiers: string[]
+ ): void {
+ if (!pattern) {
+ return;
+ }
+
+ switch (pattern.type) {
+ case 'Identifier':
+ identifiers.push(pattern.name);
+ break;
+ case 'ObjectPattern':
+ for (const prop of pattern.properties) {
+ if (prop.type === 'Property') {
+ this._collectDeclaredIdentifiers(prop.value, identifiers);
+ } else if (prop.type === 'RestElement') {
+ this._collectDeclaredIdentifiers(prop.argument, identifiers);
+ }
+ }
+ break;
+ case 'ArrayPattern':
+ for (const element of pattern.elements) {
+ this._collectDeclaredIdentifiers(element, identifiers);
+ }
+ break;
+ case 'AssignmentPattern':
+ this._collectDeclaredIdentifiers(pattern.left, identifiers);
+ break;
+ case 'RestElement':
+ this._collectDeclaredIdentifiers(pattern.argument, identifiers);
+ break;
+ default:
+ break;
+ }
+ }
+
+ /**
+ * Handle the last statement to auto-return if it's an expression.
+ */
+ private _handleLastStatement(
+ code: string,
+ ast: any
+ ): {
+ withReturn: boolean;
+ modifiedUserCode: string;
+ extraReturnCode: string;
+ } {
+ if (ast.body.length === 0) {
+ return {
+ withReturn: false,
+ modifiedUserCode: code,
+ extraReturnCode: ''
+ };
+ }
+
+ const lastNode = ast.body[ast.body.length - 1];
+
+ // If the last node is an expression statement (and not an assignment)
+ if (
+ lastNode.type === 'ExpressionStatement' &&
+ lastNode.expression.type !== 'AssignmentExpression'
+ ) {
+ const lastNodeExprStart = lastNode.expression.start;
+ const lastNodeExprEnd = lastNode.expression.end;
+ const lastNodeRestEnd = lastNode.end;
+
+ // Check for semicolon after the expression
+ let semicolonFound = false;
+ for (let i = lastNodeExprEnd; i < lastNodeRestEnd; i++) {
+ if (code[i] === ';') {
+ semicolonFound = true;
+ break;
+ }
+ }
+
+ if (!semicolonFound) {
+ // Remove the last node from the code
+ const modifiedUserCode =
+ code.substring(0, lastNodeExprStart) +
+ code.substring(lastNodeExprEnd);
+ const codeOfLastNode = code.substring(
+ lastNodeExprStart,
+ lastNodeExprEnd
+ );
+ const extraReturnCode = `return ${codeOfLastNode};`;
+
+ return {
+ withReturn: true,
+ modifiedUserCode,
+ extraReturnCode
+ };
+ }
+ }
+
+ return {
+ withReturn: false,
+ modifiedUserCode: code,
+ extraReturnCode: ''
+ };
+ }
+
+ /**
+ * Transform import source with magic imports.
+ */
+ private _transformImportSource(source: string): string {
+ if (!this._config.magicImports.enabled) {
+ return source;
+ }
+
+ // Keep absolute, relative and import-map style specifiers unchanged.
+ if (this._isDirectImportSource(source)) {
+ return source;
+ }
+
+ const { path: sourcePath, suffix } = this._splitImportSourceSuffix(source);
+
+ const transformedPath =
+ ['npm/', 'gh/'].some(start => sourcePath.startsWith(start)) ||
+ !this._config.magicImports.enableAutoNpm
+ ? sourcePath
+ : `npm/${sourcePath}`;
+
+ let transformedSource = `${this._joinBaseAndPath(
+ this._config.magicImports.baseUrl,
+ transformedPath
+ )}${suffix}`;
+
+ if (this._shouldAppendEsmSuffix(sourcePath)) {
+ transformedSource = this._appendEsmSuffix(transformedSource);
+ }
+
+ return transformedSource;
+ }
+
+ /**
+ * Whether an import source should bypass magic import transformation.
+ */
+ private _isDirectImportSource(source: string): boolean {
+ return (
+ /^(?:[a-zA-Z][a-zA-Z\d+.-]*:|\/\/)/.test(source) ||
+ source.startsWith('./') ||
+ source.startsWith('../') ||
+ source.startsWith('/') ||
+ source.startsWith('#')
+ );
+ }
+
+ /**
+ * Whether a transformed import should include the jsDelivr `+esm` suffix.
+ */
+ private _shouldAppendEsmSuffix(sourcePath: string): boolean {
+ const noEsmEnds = ['.js', '.mjs', '.cjs', '.wasm', '+esm'];
+ return !noEsmEnds.some(end => sourcePath.endsWith(end));
+ }
+
+ /**
+ * Append `+esm` before query/hash suffixes.
+ */
+ private _appendEsmSuffix(source: string): string {
+ const { path, suffix } = this._splitImportSourceSuffix(source);
+ const esmSuffix = path.endsWith('/') ? '+esm' : '/+esm';
+ return `${path}${esmSuffix}${suffix}`;
+ }
+
+ /**
+ * Split an import source into path and query/hash suffix.
+ */
+ private _splitImportSourceSuffix(source: string): {
+ path: string;
+ suffix: string;
+ } {
+ const queryIndex = source.indexOf('?');
+ const hashIndex = source.indexOf('#');
+ const splitIndex =
+ queryIndex === -1
+ ? hashIndex
+ : hashIndex === -1
+ ? queryIndex
+ : Math.min(queryIndex, hashIndex);
+
+ if (splitIndex === -1) {
+ return { path: source, suffix: '' };
+ }
+
+ return {
+ path: source.slice(0, splitIndex),
+ suffix: source.slice(splitIndex)
+ };
+ }
+
+ /**
+ * Join a base URL and import path while preserving origin semantics.
+ */
+ private _joinBaseAndPath(baseUrl: string, path: string): string {
+ const normalizedBase = baseUrl.endsWith('/') ? baseUrl : `${baseUrl}/`;
+ const normalizedPath = path.replace(/^\/+/, '');
+
+ try {
+ return new URL(normalizedPath, normalizedBase).toString();
+ } catch {
+ return `${normalizedBase}${normalizedPath}`;
+ }
+ }
+
+ /**
+ * Rewrite import statements to dynamic imports.
+ */
+ private _rewriteImportStatements(
+ code: string,
+ ast: any
+ ): {
+ modifiedUserCode: string;
+ codeAddToGlobalScope: string;
+ } {
+ let modifiedUserCode = code;
+ let codeAddToGlobalScope = '';
+
+ // Process imports in reverse order to maintain correct positions
+ for (let i = ast.body.length - 1; i >= 0; i--) {
+ const node = ast.body[i];
+
+ if (node.type === 'ImportDeclaration') {
+ const importSource = this._transformImportSource(node.source.value);
+ const importSourceCode = JSON.stringify(importSource);
+
+ if (node.specifiers.length === 0) {
+ // Side-effect import: import 'module'
+ modifiedUserCode = this._replaceCode(
+ modifiedUserCode,
+ node.start,
+ node.end,
+ `await import(${importSourceCode});\n`
+ );
+ } else {
+ let hasDefaultImport = false;
+ let defaultImportName = '';
+ let hasNamespaceImport = false;
+ let namespaceImportName = '';
+ const importedNames: string[] = [];
+ const localNames: string[] = [];
+
+ // Get imported and local names
+ for (const specifier of node.specifiers) {
+ if (specifier.type === 'ImportSpecifier') {
+ if (specifier.imported.name === 'default') {
+ hasDefaultImport = true;
+ defaultImportName = specifier.local.name;
+ } else {
+ importedNames.push(specifier.imported.name);
+ localNames.push(specifier.local.name);
+ }
+ } else if (specifier.type === 'ImportDefaultSpecifier') {
+ hasDefaultImport = true;
+ defaultImportName = specifier.local.name;
+ } else if (specifier.type === 'ImportNamespaceSpecifier') {
+ hasNamespaceImport = true;
+ namespaceImportName = specifier.local.name;
+ }
+ }
+
+ const importBinding = `__jsKernelImport${i}`;
+ let newCodeOfNode = `const ${importBinding} = await import(${importSourceCode});\n`;
+
+ const destructuredNames: string[] = [];
+ if (hasDefaultImport) {
+ destructuredNames.push(`default: ${defaultImportName}`);
+ codeAddToGlobalScope +=
+ this._addToGlobalThisCode(defaultImportName);
+ }
+
+ if (importedNames.length > 0) {
+ for (let j = 0; j < importedNames.length; j++) {
+ // Handle aliased imports: import { foo as bar } -> const { foo: bar }
+ destructuredNames.push(
+ importedNames[j] !== localNames[j]
+ ? `${importedNames[j]}: ${localNames[j]}`
+ : importedNames[j]
+ );
+ // Use local name for globalThis assignment since that's what's in scope
+ codeAddToGlobalScope += this._addToGlobalThisCode(localNames[j]);
+ }
+ }
+
+ if (destructuredNames.length > 0) {
+ newCodeOfNode += `const { ${destructuredNames.join(
+ ', '
+ )} } = ${importBinding};\n`;
+ }
+
+ if (hasNamespaceImport) {
+ newCodeOfNode += `const ${namespaceImportName} = ${importBinding};\n`;
+ codeAddToGlobalScope +=
+ this._addToGlobalThisCode(namespaceImportName);
+ }
+
+ modifiedUserCode = this._replaceCode(
+ modifiedUserCode,
+ node.start,
+ node.end,
+ newCodeOfNode
+ );
+ }
+ }
+ }
+
+ return {
+ modifiedUserCode,
+ codeAddToGlobalScope
+ };
+ }
+
+ /**
+ * Escape HTML special characters.
+ */
+ private _escapeHtml(text: string): string {
+ const htmlEscapes: Record = {
+ '&': '&',
+ '<': '<',
+ '>': '>',
+ '"': '"',
+ "'": '''
+ };
+ return text.replace(/[&<>"']/g, char => htmlEscapes[char]);
+ }
+
+ /**
+ * Get custom MIME bundle from object methods.
+ * Checks for _toHtml, _toSvg, _toPng, _toJpeg, _toMime, inspect.
+ */
+ private _getCustomMimeBundle(value: any): IMimeBundle | null {
+ // Check for _toMime() first - returns a full MIME bundle directly.
+ if (typeof value._toMime === 'function') {
+ try {
+ const mimeResult = value._toMime();
+ if (mimeResult && typeof mimeResult === 'object') {
+ return mimeResult;
+ }
+ } catch {
+ // Ignore errors in custom methods
+ }
+ }
+
+ // Try each custom output method. Each returns a string for its MIME type.
+ const customMimeMethods: [string, string][] = [
+ ['_toHtml', 'text/html'],
+ ['_toSvg', 'image/svg+xml'],
+ ['_toPng', 'image/png'],
+ ['_toJpeg', 'image/jpeg'],
+ ['_toMarkdown', 'text/markdown'],
+ ['_toLatex', 'text/latex']
+ ];
+
+ const bundle: IMimeBundle = {};
+ let hasCustomOutput = false;
+
+ for (const [method, mimeType] of customMimeMethods) {
+ if (typeof value[method] === 'function') {
+ try {
+ const result = value[method]();
+ if (typeof result === 'string') {
+ bundle[mimeType] = result;
+ hasCustomOutput = true;
+ }
+ } catch {
+ // Ignore errors in custom methods
+ }
+ }
+ }
+
+ if (!hasCustomOutput) {
+ return null;
+ }
+
+ // Add text/plain representation using inspect() if available.
+ if (typeof value.inspect === 'function') {
+ try {
+ bundle['text/plain'] = value.inspect();
+ } catch {
+ bundle['text/plain'] = String(value);
+ }
+ } else {
+ bundle['text/plain'] = String(value);
+ }
+
+ return bundle;
+ }
+
+ /**
+ * Check if value is a DOM element.
+ */
+ private _isDOMElement(value: any): boolean {
+ return (
+ this._isInstanceOfRealm(value, 'HTMLElement') ||
+ this._isInstanceOfRealm(value, 'SVGElement')
+ );
+ }
+
+ /**
+ * Get MIME bundle for DOM elements.
+ */
+ private _getDOMElementMimeBundle(element: any): IMimeBundle {
+ const isCanvasElement =
+ this._isInstanceOfRealm(element, 'HTMLCanvasElement') ||
+ (typeof element?.toDataURL === 'function' &&
+ typeof element?.getContext === 'function');
+
+ // For canvas elements, try to get image data
+ if (isCanvasElement) {
+ const canvas = element as HTMLCanvasElement;
+ try {
+ const dataUrl = canvas.toDataURL('image/png');
+ const base64 = dataUrl.split(',')[1];
+ return {
+ 'image/png': base64,
+ 'text/plain': `