From ef2e95b1d3cf74f8fcc0534c866c79814e1c6249 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Wed, 3 Dec 2025 23:27:42 +0100 Subject: [PATCH 01/20] Many improvements --- examples/intro.ipynb | 276 +++ examples/magic-imports.ipynb | 221 +++ examples/rich-output.ipynb | 364 ++++ .../javascript-kernel-extension/src/index.ts | 4 +- packages/javascript-kernel/package.json | 6 +- .../javascript-kernel/src/comlink.worker.ts | 13 - packages/javascript-kernel/src/display.ts | 200 +++ packages/javascript-kernel/src/executor.ts | 1567 +++++++++++++++++ packages/javascript-kernel/src/index.ts | 3 +- packages/javascript-kernel/src/kernel.ts | 345 +++- packages/javascript-kernel/src/tokens.ts | 35 - packages/javascript-kernel/src/worker.ts | 124 -- yarn.lock | 38 +- 13 files changed, 2953 insertions(+), 243 deletions(-) create mode 100644 examples/intro.ipynb create mode 100644 examples/magic-imports.ipynb create mode 100644 examples/rich-output.ipynb delete mode 100644 packages/javascript-kernel/src/comlink.worker.ts create mode 100644 packages/javascript-kernel/src/display.ts create mode 100644 packages/javascript-kernel/src/executor.ts delete mode 100644 packages/javascript-kernel/src/tokens.ts delete mode 100644 packages/javascript-kernel/src/worker.ts 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..78ac97e --- /dev/null +++ b/examples/magic-imports.ipynb @@ -0,0 +1,221 @@ +{ + "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:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "import confetti from 'canvas-confetti';\n", + "\n", + "// Fire some confetti!\n", + "confetti({\n", + " particleCount: 100,\n", + " spread: 70,\n", + " origin: { y: 0.6 }\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 +} \ No newline at end of file diff --git a/examples/rich-output.ipynb b/examples/rich-output.ipynb new file mode 100644 index 0000000..e8daff9 --- /dev/null +++ b/examples/rich-output.ipynb @@ -0,0 +1,364 @@ +{ + "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": [ + "## 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 `${paths}`;\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 +} \ No newline at end of file diff --git a/packages/javascript-kernel-extension/src/index.ts b/packages/javascript-kernel-extension/src/index.ts index dd526a2..029a3bd 100644 --- a/packages/javascript-kernel-extension/src/index.ts +++ b/packages/javascript-kernel-extension/src/index.ts @@ -27,13 +27,13 @@ const kernel: JupyterFrontEndPlugin = { kernelspecs.register({ spec: { name: 'javascript', - display_name: 'JavaScript (Web Worker)', + display_name: 'JavaScript', language: 'javascript', argv: [], spec: { argv: [], env: {}, - display_name: 'JavaScript (Web Worker)', + display_name: 'JavaScript', language: 'javascript', interrupt_mode: 'message', metadata: {} diff --git a/packages/javascript-kernel/package.json b/packages/javascript-kernel/package.json index fc88ffe..4146c37 100644 --- a/packages/javascript-kernel/package.json +++ b/packages/javascript-kernel/package.json @@ -45,15 +45,15 @@ "dependencies": { "@jupyterlab/coreutils": "^6.0.0", "@jupyterlite/services": "^0.7.0", - "comlink": "^4.3.1", - "object-inspect": "^1.13.1" + "@lumino/coreutils": "^2.0.0", + "astring": "^1.9.0", + "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..ef431f0 --- /dev/null +++ b/packages/javascript-kernel/src/display.ts @@ -0,0 +1,200 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +/** + * MIME bundle for rich display + */ +export interface IMimeBundle { + [key: string]: any; +} + +/** + * Display request from $$.display() + */ +export interface IDisplayData { + data: IMimeBundle; + metadata: Record; + transient?: { + display_id?: string; + }; +} + +/** + * 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 { + private _displayCallback?: (data: IDisplayData) => void; + private _clearCallback?: (wait: boolean) => void; + private _displayId?: string; + private _result?: IMimeBundle; + + constructor(displayId?: string) { + this._displayId = displayId; + } + + /** + * Set the callbacks for display operations + */ + setCallbacks(callbacks: IDisplayCallbacks): void { + this._displayCallback = callbacks.onDisplay; + this._clearCallback = callbacks.onClear; + } + + /** + * Get the result if set via display methods + */ + getResult(): IMimeBundle | undefined { + return this._result; + } + + /** + * Clear the result + */ + clearResult(): void { + this._result = undefined; + } + + /** + * Create a new display with optional ID + * Usage: $$.display('my-id').html('
...
') + */ + display(id?: string): DisplayHelper { + return new DisplayHelper(id); + } + + /** + * Display HTML content + */ + html(content: string, metadata?: Record): void { + this._sendDisplay( + { 'text/html': content, 'text/plain': content }, + metadata + ); + } + + /** + * Display SVG content + */ + svg(content: string, metadata?: Record): void { + this._sendDisplay( + { + 'image/svg+xml': content, + 'text/plain': '[SVG Image]' + }, + metadata + ); + } + + /** + * Display PNG image (base64 encoded) + */ + png(base64Content: string, metadata?: Record): void { + this._sendDisplay( + { + 'image/png': base64Content, + 'text/plain': '[PNG Image]' + }, + metadata + ); + } + + /** + * Display JPEG image (base64 encoded) + */ + jpeg(base64Content: string, metadata?: Record): void { + this._sendDisplay( + { + 'image/jpeg': base64Content, + 'text/plain': '[JPEG Image]' + }, + metadata + ); + } + + /** + * Display plain text + */ + text(content: string, metadata?: Record): void { + this._sendDisplay({ 'text/plain': content }, metadata); + } + + /** + * Display Markdown content + */ + markdown(content: string, metadata?: Record): void { + this._sendDisplay( + { 'text/markdown': content, 'text/plain': content }, + metadata + ); + } + + /** + * Display LaTeX content + */ + latex(content: string, metadata?: Record): void { + this._sendDisplay( + { 'text/latex': content, 'text/plain': content }, + metadata + ); + } + + /** + * Display JSON content + */ + json(content: any, metadata?: Record): void { + this._sendDisplay( + { + 'application/json': content, + 'text/plain': JSON.stringify(content, null, 2) + }, + metadata + ); + } + + /** + * Display with custom MIME bundle + */ + mime(mimeBundle: IMimeBundle, metadata?: Record): void { + this._sendDisplay(mimeBundle, metadata); + } + + /** + * Clear the current output + * @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; + } + } +} diff --git a/packages/javascript-kernel/src/executor.ts b/packages/javascript-kernel/src/executor.ts new file mode 100644 index 0000000..0e7b296 --- /dev/null +++ b/packages/javascript-kernel/src/executor.ts @@ -0,0 +1,1567 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { parseScript } from 'meriyah'; + +import { IMimeBundle } from './display'; + +// Re-export display types +export { + IMimeBundle, + 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; +} + +/** + * Result of code completion + */ +export interface ICompletionResult { + matches: string[]; + cursorStart: number; + cursorEnd?: number; + status?: string; +} + +/** + * Result of code completeness check + */ +export interface IIsCompleteResult { + status: 'complete' | 'incomplete' | 'invalid' | 'unknown'; + indent?: string; +} + +/** + * Result of code inspection + */ +export interface IInspectResult { + found: boolean; + data: IMimeBundle; + metadata: Record; +} + +/** + * Configuration for the JavaScript executor + */ +export class ExecutorConfig { + magicImports: IMagicImportsConfig = { + enabled: true, + baseUrl: 'https://cdn.jsdelivr.net/', + enableAutoNpm: true + }; +} + +/** + * JavaScript code executor with advanced features + */ +export class JavaScriptExecutor { + private config: ExecutorConfig; + private globalScope: Window; + + constructor(globalScope: Window, config?: ExecutorConfig) { + this.globalScope = globalScope; + this.config = config || new ExecutorConfig(); + } + + /** + * Add code to export top-level variables to global scope + */ + private addToGlobalThisCode(key: string, identifier = key): string { + return `globalThis["${key}"] = ${identifier};`; + } + + /** + * 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(`globalThis["${name}"] = ${name};`); + } else if (node.type === 'ClassDeclaration') { + const name = node.id.name; + extraCode.push(`globalThis["${name}"] = ${name};`); + } else if (node.type === 'VariableDeclaration') { + const declarations = node.declarations; + + for (const declaration of declarations) { + const declarationType = declaration.id.type; + + if (declarationType === 'ObjectPattern') { + // Handle object destructuring: const { a, b } = obj + for (const prop of declaration.id.properties) { + const key = prop.key.name; + + if (key === 'default') { + // Handle: const { default: defaultExport } = await import(url) + if (prop.value.type === 'Identifier') { + const value = prop.value.name; + extraCode.push(this.addToGlobalThisCode(value)); + } + } else { + extraCode.push(this.addToGlobalThisCode(key)); + } + } + } else if (declarationType === 'ArrayPattern') { + // Handle array destructuring: const [a, b] = arr + const keys = declaration.id.elements + .filter((el: any) => el !== null) + .map((element: any) => element.name); + for (const key of keys) { + extraCode.push(this.addToGlobalThisCode(key)); + } + } else if (declarationType === 'Identifier') { + extraCode.push(this.addToGlobalThisCode(declaration.id.name)); + } + } + } + } + + return extraCode.join('\n'); + } + + /** + * 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 { + const noMagicStarts = ['http://', 'https://', 'data:', 'file://', 'blob:']; + const noEmsEnds = ['.js', '.mjs', '.cjs', '.wasm', '+esm']; + + if (!this.config.magicImports.enabled) { + return source; + } + + const baseUrl = this.config.magicImports.baseUrl.endsWith('/') + ? this.config.magicImports.baseUrl + : this.config.magicImports.baseUrl + '/'; + + const addEms = !noEmsEnds.some(end => source.endsWith(end)); + const emsExtraEnd = addEms ? (source.endsWith('/') ? '+esm' : '/+esm') : ''; + + // If the source starts with http/https, don't transform + if (noMagicStarts.some(start => source.startsWith(start))) { + return source; + } + + // If it starts with npm/ or gh/, or auto npm is disabled + if ( + ['npm/', 'gh/'].some(start => source.startsWith(start)) || + !this.config.magicImports.enableAutoNpm + ) { + return `${baseUrl}${source}${emsExtraEnd}`; + } + + // Auto-prefix with npm/ + return `${baseUrl}npm/${source}${emsExtraEnd}`; + } + + /** + * 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); + + if (node.specifiers.length === 0) { + // Side-effect import: import 'module' + modifiedUserCode = this.replaceCode( + modifiedUserCode, + node.start, + node.end, + `await import("${importSource}");\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; + } + } + + let newCodeOfNode = ''; + + if (hasDefaultImport) { + newCodeOfNode += `const { default: ${defaultImportName} } = await import("${importSource}");\n`; + codeAddToGlobalScope += this.addToGlobalThisCode(defaultImportName); + } + + if (hasNamespaceImport) { + newCodeOfNode += `const ${namespaceImportName} = await import("${importSource}");\n`; + codeAddToGlobalScope += + this.addToGlobalThisCode(namespaceImportName); + } + + if (importedNames.length > 0) { + newCodeOfNode += 'const { '; + for (let j = 0; j < importedNames.length; j++) { + newCodeOfNode += importedNames[j]; + codeAddToGlobalScope += this.addToGlobalThisCode( + localNames[j], + importedNames[j] + ); + if (j < importedNames.length - 1) { + newCodeOfNode += ', '; + } + } + newCodeOfNode += ` } = await import("${importSource}");\n`; + } + + modifiedUserCode = this.replaceCode( + modifiedUserCode, + node.start, + node.end, + newCodeOfNode + ); + } + } + } + + return { + modifiedUserCode, + codeAddToGlobalScope + }; + } + + /** + * Convert user code to an async function + */ + 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} + `; + + // Inject built-in functions from 'this' (the iframe window when called) + // This is needed because new Function() scopes to parent window + const builtinsCode = `const { display, console } = this;`; + + const asyncFunction = new Function(` + const afunc = async function() { + ${builtinsCode} + ${combinedCode} + }; + return afunc; + `)(); + + return { + asyncFunction, + withReturn + }; + } + + /** + * Extract import information from code without executing it. + * Used to track imports for sketch generation. + */ + 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 window. + * This is used when generating the sketch iframe. + */ + generateImportCode(imports: IImportInfo[]): string { + if (imports.length === 0) { + return ''; + } + + const lines: string[] = []; + + for (const imp of imports) { + if (imp.defaultImport) { + lines.push( + `const { default: ${imp.defaultImport} } = await import("${imp.url}");` + ); + lines.push(`window["${imp.defaultImport}"] = ${imp.defaultImport};`); + } + + if (imp.namespaceImport) { + lines.push( + `const ${imp.namespaceImport} = await import("${imp.url}");` + ); + lines.push( + `window["${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 import("${imp.url}");`); + for (const importedName of namedKeys) { + const localName = imp.namedImports[importedName]; + lines.push(`window["${localName}"] = ${localName};`); + } + } + + // Side-effect only import (no specifiers) + if ( + !imp.defaultImport && + !imp.namespaceImport && + Object.keys(imp.namedImports).length === 0 + ) { + lines.push(`await import("${imp.url}");`); + } + } + + return lines.join('\n'); + } + + /** + * 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) + */ + 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 + if (value.trim().startsWith('<') && value.trim().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 (value instanceof Error) { + return { + 'text/plain': value.stack || value.toString(), + 'application/json': { + name: value.name, + message: value.message, + stack: value.stack + } + }; + } + + // Handle Date objects + if (value instanceof Date) { + return { + 'text/plain': value.toISOString(), + 'application/json': value.toISOString() + }; + } + + // Handle RegExp objects + if (value instanceof RegExp) { + return { 'text/plain': value.toString() }; + } + + // Handle Map + if (value instanceof Map) { + const entries = Array.from(value.entries()); + try { + return { + 'text/plain': `Map(${value.size}) { ${entries.map(([k, v]) => `${String(k)} => ${String(v)}`).join(', ')} }`, + 'application/json': Object.fromEntries(entries) + }; + } catch { + return { 'text/plain': `Map(${value.size})` }; + } + } + + // Handle Set + if (value instanceof Set) { + const items = Array.from(value); + try { + return { + 'text/plain': `Set(${value.size}) { ${items.map(v => String(v)).join(', ')} }`, + 'application/json': items + }; + } catch { + return { 'text/plain': `Set(${value.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 (value instanceof Promise) { + return { 'text/plain': 'Promise { }' }; + } + + // Handle generic objects + if (typeof value === 'object') { + // Check if it's already a mime bundle + if ('data' in value && typeof value.data === 'object') { + 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) }; + } + + /** + * 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 { + const bundle: IMimeBundle = {}; + let hasCustomOutput = false; + + // Check for _toMime() - returns full MIME bundle + if (typeof value._toMime === 'function') { + try { + const mimeResult = value._toMime(); + if (mimeResult && typeof mimeResult === 'object') { + return mimeResult; + } + } catch { + // Ignore errors in custom methods + } + } + + // Check for _toHtml() + if (typeof value._toHtml === 'function') { + try { + const html = value._toHtml(); + if (typeof html === 'string') { + bundle['text/html'] = html; + hasCustomOutput = true; + } + } catch { + // Ignore errors + } + } + + // Check for _toSvg() + if (typeof value._toSvg === 'function') { + try { + const svg = value._toSvg(); + if (typeof svg === 'string') { + bundle['image/svg+xml'] = svg; + hasCustomOutput = true; + } + } catch { + // Ignore errors + } + } + + // Check for _toPng() - should return base64 string + if (typeof value._toPng === 'function') { + try { + const png = value._toPng(); + if (typeof png === 'string') { + bundle['image/png'] = png; + hasCustomOutput = true; + } + } catch { + // Ignore errors + } + } + + // Check for _toJpeg() - should return base64 string + if (typeof value._toJpeg === 'function') { + try { + const jpeg = value._toJpeg(); + if (typeof jpeg === 'string') { + bundle['image/jpeg'] = jpeg; + hasCustomOutput = true; + } + } catch { + // Ignore errors + } + } + + // Check for _toMarkdown() + if (typeof value._toMarkdown === 'function') { + try { + const md = value._toMarkdown(); + if (typeof md === 'string') { + bundle['text/markdown'] = md; + hasCustomOutput = true; + } + } catch { + // Ignore errors + } + } + + // Check for _toLatex() + if (typeof value._toLatex === 'function') { + try { + const latex = value._toLatex(); + if (typeof latex === 'string') { + bundle['text/latex'] = latex; + hasCustomOutput = true; + } + } catch { + // Ignore errors + } + } + + // Add text/plain representation + if (hasCustomOutput) { + // Use custom inspect() if available, otherwise use toString() + 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; + } + + return null; + } + + /** + * Check if value is a DOM element + */ + private isDOMElement(value: any): boolean { + return typeof HTMLElement !== 'undefined' && value instanceof HTMLElement; + } + + /** + * Get MIME bundle for DOM elements + */ + private getDOMElementMimeBundle(element: HTMLElement): IMimeBundle { + // For canvas elements, try to get image data + if (element instanceof HTMLCanvasElement) { + try { + const dataUrl = element.toDataURL('image/png'); + const base64 = dataUrl.split(',')[1]; + return { + 'image/png': base64, + 'text/plain': `` + }; + } catch { + return { 'text/plain': element.outerHTML }; + } + } + + // For other elements, return HTML + return { + 'text/html': element.outerHTML, + 'text/plain': element.outerHTML + }; + } + + /** + * Format array preview with truncation + */ + private formatArrayPreview(arr: any[], maxItems: number = 10): string { + if (arr.length === 0) { + return '[]'; + } + const items = arr.slice(0, maxItems).map(item => { + if (item === null) { + return 'null'; + } + if (item === undefined) { + return 'undefined'; + } + if (typeof item === 'string') { + return `'${item}'`; + } + if (typeof item === 'object') { + if (Array.isArray(item)) { + return `Array(${item.length})`; + } + return '{...}'; + } + return String(item); + }); + const suffix = arr.length > maxItems ? `, ... (${arr.length} items)` : ''; + return `[${items.join(', ')}${suffix}]`; + } + + /** + * Format object preview with truncation + */ + private formatObjectPreview(obj: object, maxProps: number = 5): string { + const keys = Object.keys(obj); + if (keys.length === 0) { + return '{}'; + } + const constructor = obj.constructor?.name; + const prefix = + constructor && constructor !== 'Object' ? `${constructor} ` : ''; + + const props = keys.slice(0, maxProps).map(key => { + try { + const value = (obj as any)[key]; + let valueStr: string; + if (value === null) { + valueStr = 'null'; + } else if (value === undefined) { + valueStr = 'undefined'; + } else if (typeof value === 'string') { + valueStr = `'${value.length > 20 ? value.substring(0, 20) + '...' : value}'`; + } else if (typeof value === 'object') { + valueStr = Array.isArray(value) ? `Array(${value.length})` : '{...}'; + } else if (typeof value === 'function') { + valueStr = '[Function]'; + } else { + valueStr = String(value); + } + return `${key}: ${valueStr}`; + } catch { + return `${key}: `; + } + }); + + const suffix = keys.length > maxProps ? ', ...' : ''; + return `${prefix}{ ${props.join(', ')}${suffix} }`; + } + + /** + * Format non-serializable object (circular refs, etc.) + */ + private formatNonSerializableObject(obj: object): string { + const constructor = obj.constructor?.name || 'Object'; + const keys = Object.keys(obj); + return `${constructor} { ${keys.length} properties }`; + } + + /** + * Complete code at 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 = new Function( + 'scope', + `with(scope) { return ${rootObjectStr}; }` + ); + 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 + }; + } + + /** + * Get all properties of an object including inherited ones + * Filters by prefix and returns sorted unique matches + */ + private getAllProperties(obj: any, prefix: string): string[] { + const seen = new Set(); + const matches: string[] = []; + const lowerPrefix = prefix.toLowerCase(); + + // Helper to add matching properties + const addMatching = (props: string[]) => { + for (const prop of props) { + if (!seen.has(prop) && prop.startsWith(prefix)) { + seen.add(prop); + matches.push(prop); + } + } + }; + + // Helper to add case-insensitive matches (lower priority) + const addCaseInsensitive = (props: string[]) => { + for (const prop of props) { + if ( + !seen.has(prop) && + prop.toLowerCase().startsWith(lowerPrefix) && + !prop.startsWith(prefix) + ) { + seen.add(prop); + matches.push(prop); + } + } + }; + + try { + // Walk up the prototype chain + let current = obj; + while (current !== null && current !== undefined) { + try { + // Get own property names (includes non-enumerable) + const ownProps = Object.getOwnPropertyNames(current); + addMatching(ownProps); + + // Also get enumerable properties from for...in + for (const key in current) { + if (!seen.has(key) && key.startsWith(prefix)) { + seen.add(key); + matches.push(key); + } + } + } catch { + // Some objects may throw on getOwnPropertyNames + } + + // Move up prototype chain + try { + current = Object.getPrototypeOf(current); + } catch { + break; + } + } + + // Add case-insensitive matches as secondary results + current = obj; + while (current !== null && current !== undefined) { + try { + const ownProps = Object.getOwnPropertyNames(current); + addCaseInsensitive(ownProps); + } catch { + // Ignore + } + try { + current = Object.getPrototypeOf(current); + } catch { + break; + } + } + } catch { + // Fallback to simple for...in if above fails + try { + for (const key in obj) { + if (key.startsWith(prefix)) { + matches.push(key); + } + } + } catch { + // Ignore + } + } + + // Sort matches: exact prefix matches first, then alphabetically + return matches.sort((a, b) => { + const aExact = a.startsWith(prefix); + const bExact = b.startsWith(prefix); + if (aExact && !bExact) { + return -1; + } + if (!aExact && bExact) { + return 1; + } + return a.localeCompare(b); + }); + } + + /** + * Complete request with multi-line support + */ + 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]; + + // Only match if cursor is at the end of the line + if (cursorPosInLine !== codeLine.length) { + return { + matches: [], + cursorStart: cursorPos, + cursorEnd: cursorPos + }; + } + + const lineRes = this.completeLine(codeLine); + const matches = lineRes.matches; + const inLineCursorStart = lineRes.cursorStart; + + return { + matches, + cursorStart: lineBegin + inLineCursorStart, + cursorEnd: cursorPos, + status: lineRes.status || 'ok' + }; + } + + /** + * Clean stack trace to remove internal frames + */ + 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 + */ + 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 + */ + inspect( + code: string, + cursorPos: number, + detailLevel: number = 0 + ): IInspectResult { + // Extract the word/expression at cursor position + const expression = this.extractExpressionAtCursor(code, cursorPos); + + if (!expression) { + return { + found: false, + data: {}, + metadata: {} + }; + } + + try { + // Try to evaluate the expression in the global scope + const evalFunc = new Function( + 'scope', + `with(scope) { return ${expression}; }` + ); + const value = evalFunc(this.globalScope); + + // Build inspection data + const inspectionData = this.buildInspectionData( + expression, + value, + detailLevel + ); + + return { + found: true, + data: inspectionData, + metadata: {} + }; + } catch { + // Try to provide info even if we can't evaluate + return this.inspectBuiltin(expression, detailLevel); + } + } + + /** + * Extract the expression at the cursor position + */ + private extractExpressionAtCursor( + code: string, + cursorPos: number + ): string | null { + // Find word boundaries around cursor + const beforeCursor = code.substring(0, cursorPos); + const afterCursor = code.substring(cursorPos); + + // Match identifier characters going backwards + const beforeMatch = beforeCursor.match(/[\w.$]+$/); + const afterMatch = afterCursor.match(/^[\w]*/); + + if (!beforeMatch) { + return null; + } + + return beforeMatch[0] + (afterMatch?.[0] || ''); + } + + /** + * Build rich inspection data for a value + */ + private buildInspectionData( + expression: string, + value: any, + detailLevel: number + ): IMimeBundle { + const lines: string[] = []; + + // Type information + const type = this.getTypeString(value); + lines.push(`**${expression}**: \`${type}\``); + lines.push(''); + + // Value preview + if (typeof value === 'function') { + const funcStr = value.toString(); + const signature = this.extractFunctionSignature(funcStr); + lines.push('**Signature:**'); + lines.push('```javascript'); + lines.push(signature); + lines.push('```'); + + if (detailLevel > 0) { + lines.push(''); + lines.push('**Source:**'); + lines.push('```javascript'); + lines.push(funcStr); + lines.push('```'); + } + } else if (typeof value === 'object' && value !== null) { + // List properties + const props = Object.keys(value).slice(0, 20); + if (props.length > 0) { + lines.push('**Properties:**'); + for (const prop of props) { + try { + const propType = this.getTypeString(value[prop]); + lines.push(`- \`${prop}\`: ${propType}`); + } catch { + lines.push(`- \`${prop}\`: (inaccessible)`); + } + } + if (Object.keys(value).length > 20) { + lines.push(`- ... and ${Object.keys(value).length - 20} more`); + } + } + } else { + lines.push(`**Value:** \`${String(value)}\``); + } + + return { + 'text/plain': lines.join('\n').replace(/\*\*/g, ''), + 'text/markdown': lines.join('\n') + }; + } + + /** + * Get a human-readable type string for a value + */ + private getTypeString(value: any): string { + if (value === null) { + return 'null'; + } + if (value === undefined) { + return 'undefined'; + } + if (Array.isArray(value)) { + return `Array(${value.length})`; + } + if (typeof value === 'function') { + const name = value.name || 'anonymous'; + return `function ${name}()`; + } + if (typeof value === 'object') { + const constructor = value.constructor?.name; + return constructor || 'Object'; + } + return typeof value; + } + + /** + * Extract function signature from function string + */ + private extractFunctionSignature(funcStr: string): string { + // Try to extract just the signature + const match = funcStr.match( + /^(async\s+)?function\s*(\w*)\s*\([^)]*\)|^(async\s+)?\([^)]*\)\s*=>|^(async\s+)?(\w+)\s*=>/ + ); + if (match) { + return match[0]; + } + // For methods and short functions, return first line + const firstLine = funcStr.split('\n')[0]; + return firstLine.length > 100 + ? firstLine.substring(0, 100) + '...' + : firstLine; + } + + /** + * Provide inspection info for built-in objects + * First tries runtime lookup, then falls back to predefined docs + */ + 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 { + 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 { + 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 { + found: false, + data: {}, + metadata: {} + }; + } + + /** + * Try to inspect an expression by looking it up in the global scope at runtime + */ + private inspectAtRuntime( + expression: string, + detailLevel: number + ): IInspectResult { + try { + // Try to find the value in global scope + const parts = expression.split('.'); + let value: any = this.globalScope; + + for (const part of parts) { + if (value === null || value === undefined) { + return { found: false, data: {}, metadata: {} }; + } + const hasProp = + part in value || Object.prototype.hasOwnProperty.call(value, part); + if (hasProp) { + value = value[part]; + } else { + return { found: false, data: {}, metadata: {} }; + } + } + + // Build inspection data with additional documentation if available + const inspectionData = this.buildInspectionData( + expression, + value, + detailLevel + ); + + // Add predefined documentation if available + const doc = this.getBuiltinDocumentation(expression); + if (doc) { + const mdContent = inspectionData['text/markdown'] || ''; + inspectionData['text/markdown'] = mdContent + `\n\n---\n\n${doc}`; + const plainContent = inspectionData['text/plain'] || ''; + inspectionData['text/plain'] = plainContent + `\n\nDoc: ${doc}`; + } + + return { + found: true, + data: inspectionData, + metadata: {} + }; + } catch { + return { found: false, data: {}, metadata: {} }; + } + } + + /** + * Find similar names in global scope for suggestions + */ + private findSimilarNames(expression: string): string[] { + const suggestions: string[] = []; + const lowerExpr = expression.toLowerCase(); + + try { + // Check global scope for similar names + const globalProps = this.getAllProperties(this.globalScope, ''); + + for (const prop of globalProps) { + // Check for similar names (Levenshtein-like simple check) + const lowerProp = prop.toLowerCase(); + if ( + lowerProp.includes(lowerExpr) || + lowerExpr.includes(lowerProp) || + this.isSimilar(lowerExpr, lowerProp) + ) { + suggestions.push(prop); + if (suggestions.length >= 5) { + break; + } + } + } + } catch { + // Ignore errors + } + + return suggestions; + } + + /** + * Simple similarity check for two strings + */ + private isSimilar(a: string, b: string): boolean { + // Check if strings differ by only 1-2 characters + if (Math.abs(a.length - b.length) > 2) { + return false; + } + + let differences = 0; + const maxLen = Math.max(a.length, b.length); + for (let i = 0; i < maxLen; i++) { + if (a[i] !== b[i]) { + differences++; + if (differences > 2) { + return false; + } + } + } + return differences <= 2; + } + + /** + * Get predefined documentation for built-in JavaScript objects. + * Subclasses can override this to add domain-specific documentation. + */ + 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; + } +} diff --git a/packages/javascript-kernel/src/index.ts b/packages/javascript-kernel/src/index.ts index d9cd75d..ce3f2fa 100644 --- a/packages/javascript-kernel/src/index.ts +++ b/packages/javascript-kernel/src/index.ts @@ -2,4 +2,5 @@ // Distributed under the terms of the Modified BSD License. export * from './kernel'; -export * from './tokens'; +export * from './executor'; +export * from './display'; diff --git a/packages/javascript-kernel/src/kernel.ts b/packages/javascript-kernel/src/kernel.ts index cd91180..7e7c937 100644 --- a/packages/javascript-kernel/src/kernel.ts +++ b/packages/javascript-kernel/src/kernel.ts @@ -1,4 +1,5 @@ -import { PageConfig } from '@jupyterlab/coreutils'; +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. import type { KernelMessage } from '@jupyterlab/services'; @@ -6,12 +7,10 @@ import { BaseKernel, type IKernel } from '@jupyterlite/services'; import { PromiseDelegate } from '@lumino/coreutils'; -import { wrap } from 'comlink'; - -import { IRemoteJavaScriptWorkerKernel } from './tokens'; +import { JavaScriptExecutor } from './executor'; /** - * A kernel that executes code in an IFrame. + * A kernel that executes JavaScript code in an IFrame. */ export class JavaScriptKernel extends BaseKernel implements IKernel { /** @@ -21,10 +20,7 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { */ constructor(options: JavaScriptKernel.IOptions) { super(options); - this._worker = this.initWorker(options); - this._worker.onmessage = e => this._processWorkerMessage(e.data); - this.remoteKernel = this.initRemote(options); - this._ready.resolve(); + this._initIFrame(); } /** @@ -34,8 +30,7 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { if (this.isDisposed) { return; } - this._worker.terminate(); - (this._worker as any) = null; + this._cleanupIFrame(); super.dispose(); } @@ -46,6 +41,22 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { return this._ready.promise; } + /** + * Get the executor instance. + * Subclasses can use this to access executor functionality. + */ + protected get executor(): JavaScriptExecutor | undefined { + return this._executor; + } + + /** + * Get the iframe element. + * Subclasses can use this for custom iframe operations. + */ + protected get iframe(): HTMLIFrameElement { + return this._iframe; + } + /** * Handle a kernel_info_request message */ @@ -80,25 +91,107 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { /** * Handle an `execute_request` message * - * @param msg The parent message. + * @param content The content of the request. */ async executeRequest( content: KernelMessage.IExecuteRequestMsg['content'] ): Promise { - const result = await this.remoteKernel.execute(content, this.parent); - result.execution_count = this.executionCount; - return result; + const { code } = content; + + if (!this._executor) { + return { + status: 'error', + execution_count: this.executionCount, + ename: 'ExecutorError', + evalue: 'Executor not initialized', + traceback: [] + }; + } + + try { + // Use the executor to create an async function from the code + const { asyncFunction, withReturn } = + this._executor.makeAsyncFromCode(code); + + // Execute the async function in the iframe context + const resultPromise = this._evalFunc( + this._iframe.contentWindow, + asyncFunction + ); + + if (withReturn) { + const resultHolder = await resultPromise; + const result = resultHolder[0]; + // Skip undefined results (e.g., from console.log) + if (result !== undefined) { + const data = this._executor.getMimeBundle(result); + + this.publishExecuteResult({ + execution_count: this.executionCount, + data, + metadata: {} + }); + } + } else { + await resultPromise; + } + + return { + status: 'ok', + execution_count: this.executionCount, + user_expressions: {} + }; + } catch (e) { + const error = e as Error; + const { name, message } = error; + + // Use executor to clean stack trace + const cleanedStack = this._executor.cleanStackTrace(error); + + this.publishExecuteError({ + ename: name || 'Error', + evalue: message || '', + traceback: [cleanedStack] + }); + + return { + status: 'error', + execution_count: this.executionCount, + ename: name || 'Error', + evalue: message || '', + traceback: [cleanedStack] + }; + } } /** - * Handle an complete_request message + * Handle a complete_request message * - * @param msg The parent message. + * @param content The content of the request. */ async completeRequest( content: KernelMessage.ICompleteRequestMsg['content'] ): Promise { - return await this.remoteKernel.complete(content, this.parent); + if (!this._executor) { + return { + matches: [], + cursor_start: content.cursor_pos, + cursor_end: content.cursor_pos, + metadata: {}, + status: 'ok' + }; + } + + const { code, cursor_pos } = content; + const result = this._executor.completeRequest(code, cursor_pos); + + return { + matches: result.matches, + cursor_start: result.cursorStart, + cursor_end: result.cursorEnd || cursor_pos, + metadata: {}, + status: 'ok' + }; } /** @@ -111,7 +204,24 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { async inspectRequest( content: KernelMessage.IInspectRequestMsg['content'] ): Promise { - throw new Error('Not implemented'); + if (!this._executor) { + return { + status: 'ok', + found: false, + data: {}, + metadata: {} + }; + } + + const { code, cursor_pos, detail_level } = content; + const result = this._executor.inspect(code, cursor_pos, detail_level); + + return { + status: 'ok', + found: result.found, + data: result.data, + metadata: result.metadata + }; } /** @@ -124,7 +234,19 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { async isCompleteRequest( content: KernelMessage.IIsCompleteRequestMsg['content'] ): Promise { - throw new Error('Not implemented'); + if (!this._executor) { + return { + status: 'unknown' + }; + } + + const { code } = content; + const result = this._executor.isComplete(code); + + return { + status: result.status, + indent: result.indent || '' + }; } /** @@ -137,7 +259,10 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { async commInfoRequest( content: KernelMessage.ICommInfoRequestMsg['content'] ): Promise { - throw new Error('Not implemented'); + return { + status: 'ok', + comms: {} + }; } /** @@ -177,44 +302,154 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { } /** - * Load the worker. - * - * ### Note + * Execute code in the kernel IFrame. * - * Subclasses must implement this typographically almost _exactly_ for - * webpack to find it. + * @param code The code to execute. + */ + protected _eval(code: string): any { + return this._evalCodeFunc(this._iframe.contentWindow, code); + } + + /** + * Initialize the IFrame and set up communication. */ - protected initWorker(options: JavaScriptKernel.IOptions): Worker { - return new Worker(new URL('./comlink.worker.js', import.meta.url), { - type: 'module' + protected async _initIFrame(): Promise { + this._container = document.createElement('div'); + this._container.style.cssText = + 'position:absolute;width:0;height:0;overflow:hidden;'; + document.body.appendChild(this._container); + + // Create the iframe with sandbox permissions + this._iframe = document.createElement('iframe'); + this._iframe.sandbox.add('allow-scripts', 'allow-same-origin'); + this._iframe.style.cssText = 'border:none;width:100%;height:100%;'; + + this._iframe.srcdoc = ` + + + + JavaScript Kernel + + +`; + + this._container.appendChild(this._iframe); + + // Wait for iframe to load + await new Promise(resolve => { + this._iframe.onload = () => resolve(); }); + + // Set up console overrides in the iframe + this._setupConsoleOverrides(); + + // Set up message handling for console output + this._messageHandler = (event: MessageEvent) => { + if (event.source === this._iframe.contentWindow) { + this._processMessage(event.data); + } + }; + window.addEventListener('message', this._messageHandler); + + // Initialize the executor with the iframe's window + if (this._iframe.contentWindow) { + this._executor = new JavaScriptExecutor(this._iframe.contentWindow); + this._setupDisplay(); + } + + this._ready.resolve(); } /** - * Initialize the remote kernel. - * - * @param options The options for the remote kernel. - * @returns The initialized remote kernel. + * Set up the display() function in the iframe. + */ + protected _setupDisplay(): void { + if (!this._iframe.contentWindow || !this._executor) { + return; + } + + const executor = this._executor; + const kernel = this; + + // Create display function that uses executor's getMimeBundle + // and calls kernel's displayData directly + const display = (obj: any, metadata?: Record) => { + const data = executor.getMimeBundle(obj); + kernel.displayData( + { data, metadata: metadata ?? {}, transient: {} }, + kernel.parentHeader + ); + }; + + // Expose display in the iframe's global scope + (this._iframe.contentWindow as any).display = display; + } + + /** + * Set up console overrides in the iframe to bubble output to parent. + */ + protected _setupConsoleOverrides(): void { + if (!this._iframe.contentWindow) { + return; + } + + this._evalCodeFunc( + this._iframe.contentWindow, + ` + console._log = console.log; + console._error = console.error; + window._bubbleUp = function(msg) { + window.parent.postMessage(msg, '*'); + }; + console.log = function() { + const args = Array.prototype.slice.call(arguments); + window._bubbleUp({ + type: 'stream', + bundle: { name: 'stdout', text: args.join(' ') + '\\n' } + }); + }; + console.info = console.log; + console.error = function() { + const args = Array.prototype.slice.call(arguments); + window._bubbleUp({ + type: 'stream', + bundle: { name: 'stderr', text: args.join(' ') + '\\n' } + }); + }; + console.warn = console.error; + window.onerror = function(message, source, lineno, colno, error) { + console.error(message); + }; + ` + ); + } + + /** + * Clean up the iframe resources. */ - protected initRemote( - options: JavaScriptKernel.IOptions - ): IRemoteJavaScriptWorkerKernel { - const remote: IRemoteJavaScriptWorkerKernel = wrap(this._worker); - remote.initialize({ baseUrl: PageConfig.getBaseUrl() }); - return remote; + protected _cleanupIFrame(): void { + if (this._messageHandler) { + window.removeEventListener('message', this._messageHandler); + this._messageHandler = null; + } + this._iframe.remove(); + if (this._container) { + this._container.remove(); + this._container = null; + } } /** - * Process a message coming from the JavaScript web worker. + * Process a message coming from the IFrame. * - * @param msg The worker message to process. + * @param msg The message to process. */ - private _processWorkerMessage(msg: any): void { - if (!msg.type) { + protected _processMessage(msg: any): void { + if (!msg || !msg.type) { return; } - const parentHeader = msg.parentHeader || this.parentHeader; + const parentHeader = this.parentHeader; switch (msg.type) { case 'stream': { @@ -271,16 +506,32 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { } } - protected remoteKernel: IRemoteJavaScriptWorkerKernel; + // Function to execute an async function in the iframe context + private _evalFunc = (win: Window | null, asyncFunc: () => Promise) => { + if (!win) { + throw new Error('IFrame window not available'); + } + return asyncFunc.call(win); + }; + + // Function to execute raw code string in the iframe context + private _evalCodeFunc = new Function( + 'window', + 'code', + 'return window.eval(code);' + ) as (win: Window | null, code: string) => any; - private _worker: Worker; + private _iframe!: HTMLIFrameElement; + private _container: HTMLDivElement | null = null; + private _messageHandler: ((event: MessageEvent) => void) | null = null; + private _executor?: JavaScriptExecutor; private _ready = new PromiseDelegate(); } /** * A namespace for JavaScriptKernel statics */ -namespace JavaScriptKernel { +export namespace JavaScriptKernel { /** * The instantiation options for a JavaScript kernel. */ diff --git a/packages/javascript-kernel/src/tokens.ts b/packages/javascript-kernel/src/tokens.ts deleted file mode 100644 index 0702719..0000000 --- a/packages/javascript-kernel/src/tokens.ts +++ /dev/null @@ -1,35 +0,0 @@ -// Copyright (c) Jupyter Development Team. -// Distributed under the terms of the Modified BSD License. - -/** - * Definitions for the JavaScript kernel. - */ - -import type { Remote } from 'comlink'; - -import { IWorkerKernel } from '@jupyterlite/services'; - -/** - * An interface for JavaScript workers. - */ -export interface IJavaScriptWorkerKernel extends IWorkerKernel { - /** - * Handle any lazy initialization activities. - */ - initialize(options: IJavaScriptWorkerKernel.IOptions): Promise; -} - -/** - * An convenience interface for JavaScript workers wrapped by a comlink Remote. - */ -export interface IRemoteJavaScriptWorkerKernel extends Remote {} - -/** - * An namespace for JavaScript workers. - */ -export namespace IJavaScriptWorkerKernel { - /** - * Initialization options for a worker. - */ - export interface IOptions extends IWorkerKernel.IOptions {} -} diff --git a/packages/javascript-kernel/src/worker.ts b/packages/javascript-kernel/src/worker.ts deleted file mode 100644 index 0a25d1c..0000000 --- a/packages/javascript-kernel/src/worker.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { IJavaScriptWorkerKernel } from './tokens'; -import { KernelMessage } from '@jupyterlab/services'; -import objectInspect from 'object-inspect'; - -export class JavaScriptRemoteKernel { - /** - * Initialize the remote kernel. - * - * @param options The options for the kernel. - */ - async initialize(options: IJavaScriptWorkerKernel.IOptions) { - // eslint-disable-next-line no-console - console.log = function (...args) { - const bundle = { - name: 'stdout', - text: args.join(' ') + '\n' - }; - postMessage({ - type: 'stream', - bundle - }); - }; - // eslint-disable-next-line no-console - console.info = console.log; - - console.error = function (...args) { - const bundle = { - name: 'stderr', - text: args.join(' ') + '\n' - }; - postMessage({ - type: 'stream', - bundle - }); - }; - console.warn = console.error; - - self.onerror = function (message, source, lineno, colno, error) { - console.error(message); - }; - } - - /** - * Execute code in the worker kernel. - */ - async execute(content: any, parent: any) { - const { code } = content; - try { - const result = self.eval(code) as unknown; - this._executionCount++; - - const textPlain = this._inspect(result); - const data: { ['text/plain']?: string } = {}; - if (typeof textPlain === 'string') { - data['text/plain'] = textPlain; - } - - const bundle: KernelMessage.IExecuteResultMsg['content'] = { - data, - metadata: {}, - execution_count: this._executionCount - }; - postMessage({ - bundle, - type: 'execute_result' - }); - - return { - status: 'ok', - user_expressions: {} - }; - } catch (e) { - const { name, stack, message } = e as any as Error; - const bundle = { - ename: name, - evalue: message, - traceback: [`${stack}`] - }; - - postMessage({ - bundle, - type: 'execute_error' - }); - - return { - status: 'error', - ename: name, - evalue: message, - traceback: [`${stack}`] - }; - } - } - - /** - * Handle the complete message - */ - async complete(content: any, parent: any) { - // naive completion on window names only - // TODO: improve and move logic to the iframe - const vars = Object.getOwnPropertyNames(self); - const { code, cursor_pos } = content; - const words = code.slice(0, cursor_pos).match(/(\w+)$/) ?? []; - const word = words[0] ?? ''; - const matches = vars.filter(v => v.startsWith(word)); - - return { - matches, - cursor_start: cursor_pos - word.length, - cursor_end: cursor_pos, - metadata: {}, - status: 'ok' - }; - } - - private _inspect(val: unknown): string | undefined { - if (typeof val === 'undefined') { - return undefined; - } else { - return objectInspect(val); - } - } - - private _executionCount = 0; -} diff --git a/yarn.lock b/yarn.lock index af26127..2a4847f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3388,11 +3388,11 @@ __metadata: "@jupyterlab/coreutils": ^6.0.0 "@jupyterlab/testutils": ~4.5.0 "@jupyterlite/services": ^0.7.0 + "@lumino/coreutils": ^2.0.0 "@types/jest": ^26.0.10 - "@types/object-inspect": ^1.8.4 - comlink: ^4.3.1 + astring: ^1.9.0 jest: ^26.4.2 - object-inspect: ^1.13.1 + meriyah: ^4.3.9 rimraf: ~5.0.1 ts-jest: ^26.3.0 typescript: ~5.0.2 @@ -3783,7 +3783,7 @@ __metadata: languageName: node linkType: hard -"@lumino/coreutils@npm:^1.11.0 || ^2.2.2, @lumino/coreutils@npm:^2.2.2": +"@lumino/coreutils@npm:^1.11.0 || ^2.2.2, @lumino/coreutils@npm:^2.0.0, @lumino/coreutils@npm:^2.2.2": version: 2.2.2 resolution: "@lumino/coreutils@npm:2.2.2" dependencies: @@ -5113,13 +5113,6 @@ __metadata: languageName: node linkType: hard -"@types/object-inspect@npm:^1.8.4": - version: 1.13.0 - resolution: "@types/object-inspect@npm:1.13.0" - checksum: 8caf52c815947540b5246e0b5b2d455a2183791fe9427537eab8a40b465392400cee6ce50beaeb35465e167e9cb405ccfde90eb5317ee2c9df85af7508f0a320 - languageName: node - linkType: hard - "@types/parse-json@npm:^4.0.0": version: 4.0.2 resolution: "@types/parse-json@npm:4.0.2" @@ -5998,6 +5991,15 @@ __metadata: languageName: node linkType: hard +"astring@npm:^1.9.0": + version: 1.9.0 + resolution: "astring@npm:1.9.0" + bin: + astring: bin/astring + checksum: 69ffde3643f5280c6846231a995af878a94d3eab41d1a19a86b8c15f456453f63a7982cf5dd72d270b9f50dd26763a3e1e48377c961b7df16f550132b6dba805 + languageName: node + linkType: hard + "async-function@npm:^1.0.0": version: 1.0.0 resolution: "async-function@npm:1.0.0" @@ -12488,6 +12490,13 @@ __metadata: languageName: node linkType: hard +"meriyah@npm:^4.3.9": + version: 4.5.0 + resolution: "meriyah@npm:4.5.0" + checksum: 07b3225ac4528a2572d70ef6499fc97b1934558d49f968b14f167078439e7bc5e5da09fdb8bee9f8e16258fdf9822259054e6d312bd668c5678dfdf2849e3c6a + languageName: node + linkType: hard + "mermaid@npm:^11.12.1": version: 11.12.2 resolution: "mermaid@npm:11.12.2" @@ -13532,13 +13541,6 @@ __metadata: languageName: node linkType: hard -"object-inspect@npm:^1.13.1": - version: 1.13.1 - resolution: "object-inspect@npm:1.13.1" - checksum: 7d9fa9221de3311dcb5c7c307ee5dc011cdd31dc43624b7c184b3840514e118e05ef0002be5388304c416c0eb592feb46e983db12577fc47e47d5752fbbfb61f - languageName: node - linkType: hard - "object-visit@npm:^1.0.0": version: 1.0.1 resolution: "object-visit@npm:1.0.1" From d31c03e66e447f5eb801ca1c9017beeb9dab0632 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Wed, 3 Dec 2025 23:34:11 +0100 Subject: [PATCH 02/20] examples --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4782ab9..3d97852 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -112,7 +112,7 @@ jobs: python -m pip install --pre jupyterlite-core 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: From f44af3fb651b3d8adcae61fc9cc2a4e17ab79661 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Wed, 3 Dec 2025 23:36:30 +0100 Subject: [PATCH 03/20] lint --- packages/javascript-kernel/src/executor.ts | 2 +- packages/javascript-kernel/src/kernel.ts | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/javascript-kernel/src/executor.ts b/packages/javascript-kernel/src/executor.ts index 0e7b296..a49930f 100644 --- a/packages/javascript-kernel/src/executor.ts +++ b/packages/javascript-kernel/src/executor.ts @@ -403,7 +403,7 @@ export class JavaScriptExecutor { // Inject built-in functions from 'this' (the iframe window when called) // This is needed because new Function() scopes to parent window - const builtinsCode = `const { display, console } = this;`; + const builtinsCode = 'const { display, console } = this;'; const asyncFunction = new Function(` const afunc = async function() { diff --git a/packages/javascript-kernel/src/kernel.ts b/packages/javascript-kernel/src/kernel.ts index 7e7c937..74fbefa 100644 --- a/packages/javascript-kernel/src/kernel.ts +++ b/packages/javascript-kernel/src/kernel.ts @@ -369,15 +369,14 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { } const executor = this._executor; - const kernel = this; // Create display function that uses executor's getMimeBundle // and calls kernel's displayData directly const display = (obj: any, metadata?: Record) => { const data = executor.getMimeBundle(obj); - kernel.displayData( + this.displayData( { data, metadata: metadata ?? {}, transient: {} }, - kernel.parentHeader + this.parentHeader ); }; From 69df10f00920aa5f77b7c66b487c0e8a776e3b4d Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Wed, 3 Dec 2025 23:40:44 +0100 Subject: [PATCH 04/20] fix RTD --- .readthedocs.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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/ From d9c545cde6c1670f2ac962cd107deb6c893ea19a Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Wed, 3 Dec 2025 23:45:49 +0100 Subject: [PATCH 05/20] add missing jupyter-server --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3d97852..54a2635 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -109,7 +109,7 @@ 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 --contents examples From 3ab0149fae137879b0ce827b3f1a36dc652d7f46 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Fri, 5 Dec 2025 21:37:18 +0100 Subject: [PATCH 06/20] code style --- packages/javascript-kernel/src/display.ts | 95 +- packages/javascript-kernel/src/executor.ts | 1402 ++++++++++---------- packages/javascript-kernel/src/kernel.ts | 26 +- 3 files changed, 812 insertions(+), 711 deletions(-) diff --git a/packages/javascript-kernel/src/display.ts b/packages/javascript-kernel/src/display.ts index ef431f0..f94a5d2 100644 --- a/packages/javascript-kernel/src/display.ts +++ b/packages/javascript-kernel/src/display.ts @@ -2,14 +2,14 @@ // Distributed under the terms of the Modified BSD License. /** - * MIME bundle for rich display + * MIME bundle for rich display. */ export interface IMimeBundle { [key: string]: any; } /** - * Display request from $$.display() + * Display request from $$.display(). */ export interface IDisplayData { data: IMimeBundle; @@ -20,7 +20,7 @@ export interface IDisplayData { } /** - * Callbacks for display operations + * Callbacks for display operations. */ export interface IDisplayCallbacks { onDisplay?: (data: IDisplayData) => void; @@ -28,21 +28,23 @@ export interface IDisplayCallbacks { } /** - * Display helper class for rich output + * Display helper class for rich output. * Provides methods like html(), svg(), png(), etc. */ export class DisplayHelper { - private _displayCallback?: (data: IDisplayData) => void; - private _clearCallback?: (wait: boolean) => void; - private _displayId?: string; - private _result?: IMimeBundle; - + /** + * Instantiate a new DisplayHelper. + * + * @param displayId - Optional display ID for update operations. + */ constructor(displayId?: string) { this._displayId = displayId; } /** - * Set the callbacks for display operations + * Set the callbacks for display operations. + * + * @param callbacks - The callbacks for display and clear operations. */ setCallbacks(callbacks: IDisplayCallbacks): void { this._displayCallback = callbacks.onDisplay; @@ -50,29 +52,39 @@ export class DisplayHelper { } /** - * Get the result if set via display methods + * 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 + * Clear the result. */ clearResult(): void { this._result = undefined; } /** - * Create a new display with optional ID - * Usage: $$.display('my-id').html('
...
') + * 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 { return new DisplayHelper(id); } /** - * Display HTML content + * 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( @@ -82,7 +94,10 @@ export class DisplayHelper { } /** - * Display SVG content + * 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( @@ -95,7 +110,10 @@ export class DisplayHelper { } /** - * Display PNG image (base64 encoded) + * 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( @@ -108,7 +126,10 @@ export class DisplayHelper { } /** - * Display JPEG image (base64 encoded) + * 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( @@ -121,14 +142,20 @@ export class DisplayHelper { } /** - * Display plain text + * 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 + * 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( @@ -138,7 +165,10 @@ export class DisplayHelper { } /** - * Display LaTeX content + * 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( @@ -148,7 +178,10 @@ export class DisplayHelper { } /** - * Display JSON content + * 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( @@ -161,15 +194,20 @@ export class DisplayHelper { } /** - * Display with custom MIME bundle + * 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.wait If true, wait for new output before clearing + * 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) { @@ -178,7 +216,7 @@ export class DisplayHelper { } /** - * Send display data + * Send display data. */ private _sendDisplay( data: IMimeBundle, @@ -197,4 +235,9 @@ export class DisplayHelper { 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/executor.ts b/packages/javascript-kernel/src/executor.ts index a49930f..8af2b72 100644 --- a/packages/javascript-kernel/src/executor.ts +++ b/packages/javascript-kernel/src/executor.ts @@ -5,7 +5,6 @@ import { parseScript } from 'meriyah'; import { IMimeBundle } from './display'; -// Re-export display types export { IMimeBundle, IDisplayData, @@ -14,7 +13,7 @@ export { } from './display'; /** - * Configuration for magic imports + * Configuration for magic imports. */ export interface IMagicImportsConfig { enabled: boolean; @@ -23,7 +22,7 @@ export interface IMagicImportsConfig { } /** - * Result of making code async + * Result of making code async. */ export interface IAsyncCodeResult { asyncFunction: () => Promise; @@ -31,7 +30,7 @@ export interface IAsyncCodeResult { } /** - * Information about an extracted import + * Information about an extracted import. */ export interface IImportInfo { /** The original import source (e.g., 'canvas-confetti') */ @@ -47,7 +46,7 @@ export interface IImportInfo { } /** - * Result of code completion + * Result of code completion. */ export interface ICompletionResult { matches: string[]; @@ -57,7 +56,7 @@ export interface ICompletionResult { } /** - * Result of code completeness check + * Result of code completeness check. */ export interface IIsCompleteResult { status: 'complete' | 'incomplete' | 'invalid' | 'unknown'; @@ -65,7 +64,7 @@ export interface IIsCompleteResult { } /** - * Result of code inspection + * Result of code inspection. */ export interface IInspectResult { found: boolean; @@ -74,300 +73,50 @@ export interface IInspectResult { } /** - * Configuration for the JavaScript executor + * Configuration for the JavaScript executor. */ export class ExecutorConfig { - magicImports: IMagicImportsConfig = { - enabled: true, - baseUrl: 'https://cdn.jsdelivr.net/', - enableAutoNpm: true - }; -} - -/** - * JavaScript code executor with advanced features - */ -export class JavaScriptExecutor { - private config: ExecutorConfig; - private globalScope: Window; - - constructor(globalScope: Window, config?: ExecutorConfig) { - this.globalScope = globalScope; - this.config = config || new ExecutorConfig(); - } - - /** - * Add code to export top-level variables to global scope - */ - private addToGlobalThisCode(key: string, identifier = key): string { - return `globalThis["${key}"] = ${identifier};`; - } - - /** - * 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 + * Get the magic imports configuration. */ - 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(`globalThis["${name}"] = ${name};`); - } else if (node.type === 'ClassDeclaration') { - const name = node.id.name; - extraCode.push(`globalThis["${name}"] = ${name};`); - } else if (node.type === 'VariableDeclaration') { - const declarations = node.declarations; - - for (const declaration of declarations) { - const declarationType = declaration.id.type; - - if (declarationType === 'ObjectPattern') { - // Handle object destructuring: const { a, b } = obj - for (const prop of declaration.id.properties) { - const key = prop.key.name; - - if (key === 'default') { - // Handle: const { default: defaultExport } = await import(url) - if (prop.value.type === 'Identifier') { - const value = prop.value.name; - extraCode.push(this.addToGlobalThisCode(value)); - } - } else { - extraCode.push(this.addToGlobalThisCode(key)); - } - } - } else if (declarationType === 'ArrayPattern') { - // Handle array destructuring: const [a, b] = arr - const keys = declaration.id.elements - .filter((el: any) => el !== null) - .map((element: any) => element.name); - for (const key of keys) { - extraCode.push(this.addToGlobalThisCode(key)); - } - } else if (declarationType === 'Identifier') { - extraCode.push(this.addToGlobalThisCode(declaration.id.name)); - } - } - } - } - - return extraCode.join('\n'); + get magicImports(): IMagicImportsConfig { + return this._magicImports; } /** - * Handle the last statement to auto-return if it's an expression + * Set the magic imports configuration. */ - 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: '' - }; + set magicImports(value: IMagicImportsConfig) { + this._magicImports = value; } - /** - * Transform import source with magic imports - */ - private transformImportSource(source: string): string { - const noMagicStarts = ['http://', 'https://', 'data:', 'file://', 'blob:']; - const noEmsEnds = ['.js', '.mjs', '.cjs', '.wasm', '+esm']; - - if (!this.config.magicImports.enabled) { - return source; - } - - const baseUrl = this.config.magicImports.baseUrl.endsWith('/') - ? this.config.magicImports.baseUrl - : this.config.magicImports.baseUrl + '/'; - - const addEms = !noEmsEnds.some(end => source.endsWith(end)); - const emsExtraEnd = addEms ? (source.endsWith('/') ? '+esm' : '/+esm') : ''; - - // If the source starts with http/https, don't transform - if (noMagicStarts.some(start => source.startsWith(start))) { - return source; - } - - // If it starts with npm/ or gh/, or auto npm is disabled - if ( - ['npm/', 'gh/'].some(start => source.startsWith(start)) || - !this.config.magicImports.enableAutoNpm - ) { - return `${baseUrl}${source}${emsExtraEnd}`; - } - - // Auto-prefix with npm/ - return `${baseUrl}npm/${source}${emsExtraEnd}`; - } + private _magicImports: IMagicImportsConfig = { + enabled: true, + baseUrl: 'https://cdn.jsdelivr.net/', + enableAutoNpm: true + }; +} +/** + * JavaScript code executor with advanced features. + */ +export class JavaScriptExecutor { /** - * Rewrite import statements to dynamic imports + * Instantiate a new JavaScriptExecutor. + * + * @param globalScope - The global scope (window) for code execution. + * @param config - Optional executor configuration. */ - 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); - - if (node.specifiers.length === 0) { - // Side-effect import: import 'module' - modifiedUserCode = this.replaceCode( - modifiedUserCode, - node.start, - node.end, - `await import("${importSource}");\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; - } - } - - let newCodeOfNode = ''; - - if (hasDefaultImport) { - newCodeOfNode += `const { default: ${defaultImportName} } = await import("${importSource}");\n`; - codeAddToGlobalScope += this.addToGlobalThisCode(defaultImportName); - } - - if (hasNamespaceImport) { - newCodeOfNode += `const ${namespaceImportName} = await import("${importSource}");\n`; - codeAddToGlobalScope += - this.addToGlobalThisCode(namespaceImportName); - } - - if (importedNames.length > 0) { - newCodeOfNode += 'const { '; - for (let j = 0; j < importedNames.length; j++) { - newCodeOfNode += importedNames[j]; - codeAddToGlobalScope += this.addToGlobalThisCode( - localNames[j], - importedNames[j] - ); - if (j < importedNames.length - 1) { - newCodeOfNode += ', '; - } - } - newCodeOfNode += ` } = await import("${importSource}");\n`; - } - - modifiedUserCode = this.replaceCode( - modifiedUserCode, - node.start, - node.end, - newCodeOfNode - ); - } - } - } - - return { - modifiedUserCode, - codeAddToGlobalScope - }; + constructor(globalScope: Window, config?: ExecutorConfig) { + this._globalScope = globalScope; + this._config = config || new ExecutorConfig(); } /** - * Convert user code to an async function + * 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) { @@ -383,15 +132,15 @@ export class JavaScriptExecutor { }); // Add top-level variables to global scope - let codeAddToGlobalScope = this.addToGlobalScope(ast); + let codeAddToGlobalScope = this._addToGlobalScope(ast); // Handle last statement / add return if needed const { withReturn, modifiedUserCode, extraReturnCode } = - this.handleLastStatement(code, ast); + this._handleLastStatement(code, ast); let finalCode = modifiedUserCode; // Handle import statements - const importResult = this.rewriteImportStatements(finalCode, ast); + const importResult = this._rewriteImportStatements(finalCode, ast); finalCode = importResult.modifiedUserCode; codeAddToGlobalScope += importResult.codeAddToGlobalScope; @@ -422,6 +171,9 @@ export class JavaScriptExecutor { /** * 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) { @@ -439,7 +191,7 @@ export class JavaScriptExecutor { for (const node of ast.body) { if (node.type === 'ImportDeclaration') { const source = String(node.source.value); - const url = this.transformImportSource(source); + const url = this._transformImportSource(source); const importInfo: IImportInfo = { source, @@ -475,6 +227,9 @@ export class JavaScriptExecutor { /** * Generate async JavaScript code to load imports and assign them to window. * 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) { @@ -536,6 +291,9 @@ export class JavaScriptExecutor { * - _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 @@ -548,7 +306,7 @@ export class JavaScriptExecutor { // Check for custom MIME output methods if (typeof value === 'object' && value !== null) { - const customMime = this.getCustomMimeBundle(value); + const customMime = this._getCustomMimeBundle(value); if (customMime) { return customMime; } @@ -585,7 +343,7 @@ export class JavaScriptExecutor { const name = value.name || 'anonymous'; return { 'text/plain': `[Function: ${name}]`, - 'text/html': `
${this.escapeHtml(funcString)}
` + 'text/html': `
${this._escapeHtml(funcString)}
` }; } @@ -641,14 +399,14 @@ export class JavaScriptExecutor { } // Handle DOM elements (Canvas, HTMLElement, etc.) - if (this.isDOMElement(value)) { - return this.getDOMElementMimeBundle(value); + if (this._isDOMElement(value)) { + return this._getDOMElementMimeBundle(value); } // Handle arrays if (Array.isArray(value)) { try { - const preview = this.formatArrayPreview(value); + const preview = this._formatArrayPreview(value); return { 'application/json': value, 'text/plain': preview @@ -679,14 +437,14 @@ export class JavaScriptExecutor { } try { - const preview = this.formatObjectPreview(value); + 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) }; + return { 'text/plain': this._formatNonSerializableObject(value) }; } } @@ -695,13 +453,612 @@ export class JavaScriptExecutor { } /** - * Escape HTML special characters + * 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. */ - private escapeHtml(text: string): string { - const htmlEscapes: Record = { - '&': '&', - '<': '<', - '>': '>', + 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 = new Function( + 'scope', + `with(scope) { return ${rootObjectStr}; }` + ); + 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]; + + // Only match if cursor is at the end of the line + if (cursorPosInLine !== codeLine.length) { + return { + matches: [], + cursorStart: cursorPos, + cursorEnd: cursorPos + }; + } + + const lineRes = this.completeLine(codeLine); + const matches = lineRes.matches; + const inLineCursorStart = lineRes.cursorStart; + + return { + matches, + cursorStart: lineBegin + inLineCursorStart, + cursorEnd: cursorPos, + 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: number = 0 + ): IInspectResult { + // Extract the word/expression at cursor position + const expression = this._extractExpressionAtCursor(code, cursorPos); + + if (!expression) { + return { + found: false, + data: {}, + metadata: {} + }; + } + + try { + // Try to evaluate the expression in the global scope + const evalFunc = new Function( + 'scope', + `with(scope) { return ${expression}; }` + ); + const value = evalFunc(this._globalScope); + + // Build inspection data + const inspectionData = this._buildInspectionData( + expression, + value, + detailLevel + ); + + return { + 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 { + 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 { + 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 { + 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 { + return `globalThis["${key}"] = ${identifier};`; + } + + /** + * 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(`globalThis["${name}"] = ${name};`); + } else if (node.type === 'ClassDeclaration') { + const name = node.id.name; + extraCode.push(`globalThis["${name}"] = ${name};`); + } else if (node.type === 'VariableDeclaration') { + const declarations = node.declarations; + + for (const declaration of declarations) { + const declarationType = declaration.id.type; + + if (declarationType === 'ObjectPattern') { + // Handle object destructuring: const { a, b } = obj + for (const prop of declaration.id.properties) { + const key = prop.key.name; + + if (key === 'default') { + // Handle: const { default: defaultExport } = await import(url) + if (prop.value.type === 'Identifier') { + const value = prop.value.name; + extraCode.push(this._addToGlobalThisCode(value)); + } + } else { + extraCode.push(this._addToGlobalThisCode(key)); + } + } + } else if (declarationType === 'ArrayPattern') { + // Handle array destructuring: const [a, b] = arr + const keys = declaration.id.elements + .filter((el: any) => el !== null) + .map((element: any) => element.name); + for (const key of keys) { + extraCode.push(this._addToGlobalThisCode(key)); + } + } else if (declarationType === 'Identifier') { + extraCode.push(this._addToGlobalThisCode(declaration.id.name)); + } + } + } + } + + return extraCode.join('\n'); + } + + /** + * 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 { + const noMagicStarts = ['http://', 'https://', 'data:', 'file://', 'blob:']; + const noEmsEnds = ['.js', '.mjs', '.cjs', '.wasm', '+esm']; + + if (!this._config.magicImports.enabled) { + return source; + } + + const baseUrl = this._config.magicImports.baseUrl.endsWith('/') + ? this._config.magicImports.baseUrl + : this._config.magicImports.baseUrl + '/'; + + const addEms = !noEmsEnds.some(end => source.endsWith(end)); + const emsExtraEnd = addEms ? (source.endsWith('/') ? '+esm' : '/+esm') : ''; + + // If the source starts with http/https, don't transform + if (noMagicStarts.some(start => source.startsWith(start))) { + return source; + } + + // If it starts with npm/ or gh/, or auto npm is disabled + if ( + ['npm/', 'gh/'].some(start => source.startsWith(start)) || + !this._config.magicImports.enableAutoNpm + ) { + return `${baseUrl}${source}${emsExtraEnd}`; + } + + // Auto-prefix with npm/ + return `${baseUrl}npm/${source}${emsExtraEnd}`; + } + + /** + * 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); + + if (node.specifiers.length === 0) { + // Side-effect import: import 'module' + modifiedUserCode = this._replaceCode( + modifiedUserCode, + node.start, + node.end, + `await import("${importSource}");\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; + } + } + + let newCodeOfNode = ''; + + if (hasDefaultImport) { + newCodeOfNode += `const { default: ${defaultImportName} } = await import("${importSource}");\n`; + codeAddToGlobalScope += + this._addToGlobalThisCode(defaultImportName); + } + + if (hasNamespaceImport) { + newCodeOfNode += `const ${namespaceImportName} = await import("${importSource}");\n`; + codeAddToGlobalScope += + this._addToGlobalThisCode(namespaceImportName); + } + + if (importedNames.length > 0) { + newCodeOfNode += 'const { '; + for (let j = 0; j < importedNames.length; j++) { + newCodeOfNode += importedNames[j]; + codeAddToGlobalScope += this._addToGlobalThisCode( + localNames[j], + importedNames[j] + ); + if (j < importedNames.length - 1) { + newCodeOfNode += ', '; + } + } + newCodeOfNode += ` } = await import("${importSource}");\n`; + } + + modifiedUserCode = this._replaceCode( + modifiedUserCode, + node.start, + node.end, + newCodeOfNode + ); + } + } + } + + return { + modifiedUserCode, + codeAddToGlobalScope + }; + } + + /** + * Escape HTML special characters. + */ + private _escapeHtml(text: string): string { + const htmlEscapes: Record = { + '&': '&', + '<': '<', + '>': '>', '"': '"', "'": ''' }; @@ -710,9 +1067,9 @@ export class JavaScriptExecutor { /** * Get custom MIME bundle from object methods. - * Checks for _toHtml, _toSvg, _toPng, _toJpeg, _toMime, inspect + * Checks for _toHtml, _toSvg, _toPng, _toJpeg, _toMime, inspect. */ - private getCustomMimeBundle(value: any): IMimeBundle | null { + private _getCustomMimeBundle(value: any): IMimeBundle | null { const bundle: IMimeBundle = {}; let hasCustomOutput = false; @@ -728,7 +1085,6 @@ export class JavaScriptExecutor { } } - // Check for _toHtml() if (typeof value._toHtml === 'function') { try { const html = value._toHtml(); @@ -741,7 +1097,6 @@ export class JavaScriptExecutor { } } - // Check for _toSvg() if (typeof value._toSvg === 'function') { try { const svg = value._toSvg(); @@ -780,7 +1135,6 @@ export class JavaScriptExecutor { } } - // Check for _toMarkdown() if (typeof value._toMarkdown === 'function') { try { const md = value._toMarkdown(); @@ -793,7 +1147,6 @@ export class JavaScriptExecutor { } } - // Check for _toLatex() if (typeof value._toLatex === 'function') { try { const latex = value._toLatex(); @@ -825,16 +1178,16 @@ export class JavaScriptExecutor { } /** - * Check if value is a DOM element + * Check if value is a DOM element. */ - private isDOMElement(value: any): boolean { + private _isDOMElement(value: any): boolean { return typeof HTMLElement !== 'undefined' && value instanceof HTMLElement; } /** - * Get MIME bundle for DOM elements + * Get MIME bundle for DOM elements. */ - private getDOMElementMimeBundle(element: HTMLElement): IMimeBundle { + private _getDOMElementMimeBundle(element: HTMLElement): IMimeBundle { // For canvas elements, try to get image data if (element instanceof HTMLCanvasElement) { try { @@ -857,9 +1210,9 @@ export class JavaScriptExecutor { } /** - * Format array preview with truncation + * Format array preview with truncation. */ - private formatArrayPreview(arr: any[], maxItems: number = 10): string { + private _formatArrayPreview(arr: any[], maxItems: number = 10): string { if (arr.length === 0) { return '[]'; } @@ -886,127 +1239,58 @@ export class JavaScriptExecutor { } /** - * Format object preview with truncation + * Format object preview with truncation. */ - private formatObjectPreview(obj: object, maxProps: number = 5): string { + private _formatObjectPreview(obj: object, maxProps: number = 5): string { const keys = Object.keys(obj); if (keys.length === 0) { return '{}'; } const constructor = obj.constructor?.name; const prefix = - constructor && constructor !== 'Object' ? `${constructor} ` : ''; - - const props = keys.slice(0, maxProps).map(key => { - try { - const value = (obj as any)[key]; - let valueStr: string; - if (value === null) { - valueStr = 'null'; - } else if (value === undefined) { - valueStr = 'undefined'; - } else if (typeof value === 'string') { - valueStr = `'${value.length > 20 ? value.substring(0, 20) + '...' : value}'`; - } else if (typeof value === 'object') { - valueStr = Array.isArray(value) ? `Array(${value.length})` : '{...}'; - } else if (typeof value === 'function') { - valueStr = '[Function]'; - } else { - valueStr = String(value); - } - return `${key}: ${valueStr}`; - } catch { - return `${key}: `; - } - }); - - const suffix = keys.length > maxProps ? ', ...' : ''; - return `${prefix}{ ${props.join(', ')}${suffix} }`; - } - - /** - * Format non-serializable object (circular refs, etc.) - */ - private formatNonSerializableObject(obj: object): string { - const constructor = obj.constructor?.name || 'Object'; - const keys = Object.keys(obj); - return `${constructor} { ${keys.length} properties }`; - } - - /** - * Complete code at 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 = new Function( - 'scope', - `with(scope) { return ${rootObjectStr}; }` - ); - rootObject = evalFunc(globalScope); + constructor && constructor !== 'Object' ? `${constructor} ` : ''; + + const props = keys.slice(0, maxProps).map(key => { + try { + const value = (obj as any)[key]; + let valueStr: string; + if (value === null) { + valueStr = 'null'; + } else if (value === undefined) { + valueStr = 'undefined'; + } else if (typeof value === 'string') { + valueStr = `'${value.length > 20 ? value.substring(0, 20) + '...' : value}'`; + } else if (typeof value === 'object') { + valueStr = Array.isArray(value) ? `Array(${value.length})` : '{...}'; + } else if (typeof value === 'function') { + valueStr = '[Function]'; + } else { + valueStr = String(value); + } + return `${key}: ${valueStr}`; } catch { - return { - matches: [], - cursorStart, - status: 'error' - }; + return `${key}: `; } - } + }); - // Collect all properties including from prototype chain - const matches = this.getAllProperties(rootObject, toMatch); + const suffix = keys.length > maxProps ? ', ...' : ''; + return `${prefix}{ ${props.join(', ')}${suffix} }`; + } - return { - matches, - cursorStart - }; + /** + * Format non-serializable object (circular refs, etc.). + */ + private _formatNonSerializableObject(obj: object): string { + const constructor = obj.constructor?.name || 'Object'; + const keys = Object.keys(obj); + return `${constructor} { ${keys.length} properties }`; } /** - * Get all properties of an object including inherited ones - * Filters by prefix and returns sorted unique matches + * Get all properties of an object including inherited ones. + * Filters by prefix and returns sorted unique matches. */ - private getAllProperties(obj: any, prefix: string): string[] { + private _getAllProperties(obj: any, prefix: string): string[] { const seen = new Set(); const matches: string[] = []; const lowerPrefix = prefix.toLowerCase(); @@ -1106,168 +1390,9 @@ export class JavaScriptExecutor { } /** - * Complete request with multi-line support - */ - 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]; - - // Only match if cursor is at the end of the line - if (cursorPosInLine !== codeLine.length) { - return { - matches: [], - cursorStart: cursorPos, - cursorEnd: cursorPos - }; - } - - const lineRes = this.completeLine(codeLine); - const matches = lineRes.matches; - const inLineCursorStart = lineRes.cursorStart; - - return { - matches, - cursorStart: lineBegin + inLineCursorStart, - cursorEnd: cursorPos, - status: lineRes.status || 'ok' - }; - } - - /** - * Clean stack trace to remove internal frames - */ - 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 - */ - 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 - */ - inspect( - code: string, - cursorPos: number, - detailLevel: number = 0 - ): IInspectResult { - // Extract the word/expression at cursor position - const expression = this.extractExpressionAtCursor(code, cursorPos); - - if (!expression) { - return { - found: false, - data: {}, - metadata: {} - }; - } - - try { - // Try to evaluate the expression in the global scope - const evalFunc = new Function( - 'scope', - `with(scope) { return ${expression}; }` - ); - const value = evalFunc(this.globalScope); - - // Build inspection data - const inspectionData = this.buildInspectionData( - expression, - value, - detailLevel - ); - - return { - found: true, - data: inspectionData, - metadata: {} - }; - } catch { - // Try to provide info even if we can't evaluate - return this.inspectBuiltin(expression, detailLevel); - } - } - - /** - * Extract the expression at the cursor position + * Extract the expression at the cursor position. */ - private extractExpressionAtCursor( + private _extractExpressionAtCursor( code: string, cursorPos: number ): string | null { @@ -1287,9 +1412,9 @@ export class JavaScriptExecutor { } /** - * Build rich inspection data for a value + * Build rich inspection data for a value. */ - private buildInspectionData( + private _buildInspectionData( expression: string, value: any, detailLevel: number @@ -1297,14 +1422,14 @@ export class JavaScriptExecutor { const lines: string[] = []; // Type information - const type = this.getTypeString(value); + const type = this._getTypeString(value); lines.push(`**${expression}**: \`${type}\``); lines.push(''); // Value preview if (typeof value === 'function') { const funcStr = value.toString(); - const signature = this.extractFunctionSignature(funcStr); + const signature = this._extractFunctionSignature(funcStr); lines.push('**Signature:**'); lines.push('```javascript'); lines.push(signature); @@ -1324,7 +1449,7 @@ export class JavaScriptExecutor { lines.push('**Properties:**'); for (const prop of props) { try { - const propType = this.getTypeString(value[prop]); + const propType = this._getTypeString(value[prop]); lines.push(`- \`${prop}\`: ${propType}`); } catch { lines.push(`- \`${prop}\`: (inaccessible)`); @@ -1345,9 +1470,9 @@ export class JavaScriptExecutor { } /** - * Get a human-readable type string for a value + * Get a human-readable type string for a value. */ - private getTypeString(value: any): string { + private _getTypeString(value: any): string { if (value === null) { return 'null'; } @@ -1369,9 +1494,9 @@ export class JavaScriptExecutor { } /** - * Extract function signature from function string + * Extract function signature from function string. */ - private extractFunctionSignature(funcStr: string): string { + private _extractFunctionSignature(funcStr: string): string { // Try to extract just the signature const match = funcStr.match( /^(async\s+)?function\s*(\w*)\s*\([^)]*\)|^(async\s+)?\([^)]*\)\s*=>|^(async\s+)?(\w+)\s*=>/ @@ -1387,63 +1512,16 @@ export class JavaScriptExecutor { } /** - * Provide inspection info for built-in objects - * First tries runtime lookup, then falls back to predefined docs - */ - 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 { - 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 { - 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 { - found: false, - data: {}, - metadata: {} - }; - } - - /** - * Try to inspect an expression by looking it up in the global scope at runtime + * Try to inspect an expression by looking it up in the global scope at runtime. */ - private inspectAtRuntime( + private _inspectAtRuntime( expression: string, detailLevel: number ): IInspectResult { try { // Try to find the value in global scope const parts = expression.split('.'); - let value: any = this.globalScope; + let value: any = this._globalScope; for (const part of parts) { if (value === null || value === undefined) { @@ -1459,14 +1537,14 @@ export class JavaScriptExecutor { } // Build inspection data with additional documentation if available - const inspectionData = this.buildInspectionData( + const inspectionData = this._buildInspectionData( expression, value, detailLevel ); // Add predefined documentation if available - const doc = this.getBuiltinDocumentation(expression); + const doc = this._getBuiltinDocumentation(expression); if (doc) { const mdContent = inspectionData['text/markdown'] || ''; inspectionData['text/markdown'] = mdContent + `\n\n---\n\n${doc}`; @@ -1485,15 +1563,15 @@ export class JavaScriptExecutor { } /** - * Find similar names in global scope for suggestions + * Find similar names in global scope for suggestions. */ - private findSimilarNames(expression: string): string[] { + private _findSimilarNames(expression: string): string[] { const suggestions: string[] = []; const lowerExpr = expression.toLowerCase(); try { // Check global scope for similar names - const globalProps = this.getAllProperties(this.globalScope, ''); + const globalProps = this._getAllProperties(this._globalScope, ''); for (const prop of globalProps) { // Check for similar names (Levenshtein-like simple check) @@ -1501,7 +1579,7 @@ export class JavaScriptExecutor { if ( lowerProp.includes(lowerExpr) || lowerExpr.includes(lowerProp) || - this.isSimilar(lowerExpr, lowerProp) + this._isSimilar(lowerExpr, lowerProp) ) { suggestions.push(prop); if (suggestions.length >= 5) { @@ -1517,9 +1595,9 @@ export class JavaScriptExecutor { } /** - * Simple similarity check for two strings + * Simple similarity check for two strings. */ - private isSimilar(a: string, b: string): boolean { + private _isSimilar(a: string, b: string): boolean { // Check if strings differ by only 1-2 characters if (Math.abs(a.length - b.length) > 2) { return false; @@ -1538,30 +1616,6 @@ export class JavaScriptExecutor { return differences <= 2; } - /** - * Get predefined documentation for built-in JavaScript objects. - * Subclasses can override this to add domain-specific documentation. - */ - 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; - } + private _config: ExecutorConfig; + private _globalScope: Window; } diff --git a/packages/javascript-kernel/src/kernel.ts b/packages/javascript-kernel/src/kernel.ts index 74fbefa..9bc93f7 100644 --- a/packages/javascript-kernel/src/kernel.ts +++ b/packages/javascript-kernel/src/kernel.ts @@ -14,9 +14,9 @@ import { JavaScriptExecutor } from './executor'; */ export class JavaScriptKernel extends BaseKernel implements IKernel { /** - * Instantiate a new JavaScriptKernel + * Instantiate a new JavaScriptKernel. * - * @param options The instantiation options for a new JavaScriptKernel + * @param options - The instantiation options for a new JavaScriptKernel. */ constructor(options: JavaScriptKernel.IOptions) { super(options); @@ -58,7 +58,7 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { } /** - * Handle a kernel_info_request message + * Handle a kernel_info_request message. */ async kernelInfoRequest(): Promise { const content: KernelMessage.IInfoReply = { @@ -89,9 +89,9 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { } /** - * Handle an `execute_request` message + * Handle an `execute_request` message. * - * @param content The content of the request. + * @param content - The content of the request. */ async executeRequest( content: KernelMessage.IExecuteRequestMsg['content'] @@ -165,9 +165,9 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { } /** - * Handle a complete_request message + * Handle a complete_request message. * - * @param content The content of the request. + * @param content - The content of the request. */ async completeRequest( content: KernelMessage.ICompleteRequestMsg['content'] @@ -304,7 +304,7 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { /** * Execute code in the kernel IFrame. * - * @param code The code to execute. + * @param code - The code to execute. */ protected _eval(code: string): any { return this._evalCodeFunc(this._iframe.contentWindow, code); @@ -441,7 +441,7 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { /** * Process a message coming from the IFrame. * - * @param msg The message to process. + * @param msg - The message to process. */ protected _processMessage(msg: any): void { if (!msg || !msg.type) { @@ -505,7 +505,9 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { } } - // Function to execute an async function in the iframe context + /** + * Execute an async function in the iframe context. + */ private _evalFunc = (win: Window | null, asyncFunc: () => Promise) => { if (!win) { throw new Error('IFrame window not available'); @@ -513,7 +515,9 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { return asyncFunc.call(win); }; - // Function to execute raw code string in the iframe context + /** + * Execute raw code string in the iframe context. + */ private _evalCodeFunc = new Function( 'window', 'code', From ea0ed1c1cb8d52dab6ebe660b3cac3439dd65e62 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Sat, 6 Dec 2025 09:25:03 +0100 Subject: [PATCH 07/20] fix some docstrings --- packages/javascript-kernel/src/display.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/javascript-kernel/src/display.ts b/packages/javascript-kernel/src/display.ts index f94a5d2..4345c8f 100644 --- a/packages/javascript-kernel/src/display.ts +++ b/packages/javascript-kernel/src/display.ts @@ -9,7 +9,7 @@ export interface IMimeBundle { } /** - * Display request from $$.display(). + * Display request from display(). */ export interface IDisplayData { data: IMimeBundle; @@ -74,7 +74,7 @@ export class DisplayHelper { * @returns A new DisplayHelper instance. * * @example - * $$.display('my-id').html('
...
') + * display('my-id').html('
...
') */ display(id?: string): DisplayHelper { return new DisplayHelper(id); From 21f117425d50b10a24d4b21b5ef19028731d86d7 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Sat, 6 Dec 2025 10:07:22 +0100 Subject: [PATCH 08/20] more style fixes --- packages/javascript-kernel/src/executor.ts | 10 +++++----- packages/javascript-kernel/src/kernel.ts | 22 +++++++++++----------- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/packages/javascript-kernel/src/executor.ts b/packages/javascript-kernel/src/executor.ts index 8af2b72..9ea89df 100644 --- a/packages/javascript-kernel/src/executor.ts +++ b/packages/javascript-kernel/src/executor.ts @@ -695,7 +695,7 @@ export class JavaScriptExecutor { }; } catch { // Try to provide info even if we can't evaluate - return this._inspectBuiltin(expression, detailLevel); + return this.inspectBuiltin(expression, detailLevel); } } @@ -707,7 +707,7 @@ export class JavaScriptExecutor { * @param detailLevel - The level of detail requested. * @returns The inspection result. */ - protected _inspectBuiltin( + protected inspectBuiltin( expression: string, detailLevel: number ): IInspectResult { @@ -718,7 +718,7 @@ export class JavaScriptExecutor { } // Fall back to predefined documentation - const doc = this._getBuiltinDocumentation(expression); + const doc = this.getBuiltinDocumentation(expression); if (doc) { return { found: true, @@ -757,7 +757,7 @@ export class JavaScriptExecutor { * @param expression - The expression to get documentation for. * @returns The documentation string, or null if not found. */ - protected _getBuiltinDocumentation(expression: string): string | null { + protected getBuiltinDocumentation(expression: string): string | null { // Common JavaScript built-ins documentation const builtins: Record = { console: @@ -1544,7 +1544,7 @@ export class JavaScriptExecutor { ); // Add predefined documentation if available - const doc = this._getBuiltinDocumentation(expression); + const doc = this.getBuiltinDocumentation(expression); if (doc) { const mdContent = inspectionData['text/markdown'] || ''; inspectionData['text/markdown'] = mdContent + `\n\n---\n\n${doc}`; diff --git a/packages/javascript-kernel/src/kernel.ts b/packages/javascript-kernel/src/kernel.ts index 9bc93f7..0579082 100644 --- a/packages/javascript-kernel/src/kernel.ts +++ b/packages/javascript-kernel/src/kernel.ts @@ -20,7 +20,7 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { */ constructor(options: JavaScriptKernel.IOptions) { super(options); - this._initIFrame(); + this.initIFrame(); } /** @@ -30,7 +30,7 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { if (this.isDisposed) { return; } - this._cleanupIFrame(); + this.cleanupIFrame(); super.dispose(); } @@ -306,14 +306,14 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { * * @param code - The code to execute. */ - protected _eval(code: string): any { + protected evaluate(code: string): any { return this._evalCodeFunc(this._iframe.contentWindow, code); } /** * Initialize the IFrame and set up communication. */ - protected async _initIFrame(): Promise { + protected async initIFrame(): Promise { this._container = document.createElement('div'); this._container.style.cssText = 'position:absolute;width:0;height:0;overflow:hidden;'; @@ -341,12 +341,12 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { }); // Set up console overrides in the iframe - this._setupConsoleOverrides(); + this.setupConsoleOverrides(); // Set up message handling for console output this._messageHandler = (event: MessageEvent) => { if (event.source === this._iframe.contentWindow) { - this._processMessage(event.data); + this.processMessage(event.data); } }; window.addEventListener('message', this._messageHandler); @@ -354,7 +354,7 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { // Initialize the executor with the iframe's window if (this._iframe.contentWindow) { this._executor = new JavaScriptExecutor(this._iframe.contentWindow); - this._setupDisplay(); + this.setupDisplay(); } this._ready.resolve(); @@ -363,7 +363,7 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { /** * Set up the display() function in the iframe. */ - protected _setupDisplay(): void { + protected setupDisplay(): void { if (!this._iframe.contentWindow || !this._executor) { return; } @@ -387,7 +387,7 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { /** * Set up console overrides in the iframe to bubble output to parent. */ - protected _setupConsoleOverrides(): void { + protected setupConsoleOverrides(): void { if (!this._iframe.contentWindow) { return; } @@ -426,7 +426,7 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { /** * Clean up the iframe resources. */ - protected _cleanupIFrame(): void { + protected cleanupIFrame(): void { if (this._messageHandler) { window.removeEventListener('message', this._messageHandler); this._messageHandler = null; @@ -443,7 +443,7 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { * * @param msg - The message to process. */ - protected _processMessage(msg: any): void { + protected processMessage(msg: any): void { if (!msg || !msg.type) { return; } From f74cd150d0a5cba26386af76ea0d8d7f51504e63 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Mon, 8 Dec 2025 22:48:52 +0100 Subject: [PATCH 09/20] code registry --- packages/javascript-kernel/src/executor.ts | 211 ++++++++++++++++++++- 1 file changed, 208 insertions(+), 3 deletions(-) diff --git a/packages/javascript-kernel/src/executor.ts b/packages/javascript-kernel/src/executor.ts index 9ea89df..1f3ea41 100644 --- a/packages/javascript-kernel/src/executor.ts +++ b/packages/javascript-kernel/src/executor.ts @@ -2,6 +2,7 @@ // Distributed under the terms of the Modified BSD License. import { parseScript } from 'meriyah'; +import { generate } from 'astring'; import { IMimeBundle } from './display'; @@ -72,6 +73,21 @@ export interface IInspectResult { metadata: Record; } +/** + * 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. */ @@ -282,6 +298,191 @@ export class JavaScriptExecutor { 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': + // For expression statements, we need to track them + if ( + node.expression.type === 'AssignmentExpression' && + node.expression.left.type === 'Identifier' + ) { + // Named assignment like `x = 5;` + registry.statements.push(node); + } else { + // Other expressions (function calls, etc.) - keep in order + 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: @@ -431,9 +632,13 @@ export class JavaScriptExecutor { // Handle generic objects if (typeof value === 'object') { - // Check if it's already a mime bundle - if ('data' in value && typeof value.data === 'object') { - return value.data; + // 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 { From 84768c23ede01ac1c04270acbf7f4132615f2785 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Wed, 24 Dec 2025 17:47:04 +0100 Subject: [PATCH 10/20] fix --- packages/javascript-kernel/src/executor.ts | 33 +++++++++++----------- 1 file changed, 16 insertions(+), 17 deletions(-) diff --git a/packages/javascript-kernel/src/executor.ts b/packages/javascript-kernel/src/executor.ts index 1f3ea41..2fac8c7 100644 --- a/packages/javascript-kernel/src/executor.ts +++ b/packages/javascript-kernel/src/executor.ts @@ -1024,19 +1024,15 @@ export class JavaScriptExecutor { const declarationType = declaration.id.type; if (declarationType === 'ObjectPattern') { - // Handle object destructuring: const { a, b } = obj + // Handle object destructuring: const { a, b } = obj or const { a: b } = obj for (const prop of declaration.id.properties) { - const key = prop.key.name; - - if (key === 'default') { - // Handle: const { default: defaultExport } = await import(url) - if (prop.value.type === 'Identifier') { - const value = prop.value.name; - extraCode.push(this._addToGlobalThisCode(value)); - } - } else { - extraCode.push(this._addToGlobalThisCode(key)); - } + // For { a: b }, key is 'a' but local variable is 'b' + // For { a }, key and value are both 'a' + const localName = + prop.value?.type === 'Identifier' + ? prop.value.name + : prop.key.name; + extraCode.push(this._addToGlobalThisCode(localName)); } } else if (declarationType === 'ArrayPattern') { // Handle array destructuring: const [a, b] = arr @@ -1228,11 +1224,14 @@ export class JavaScriptExecutor { if (importedNames.length > 0) { newCodeOfNode += 'const { '; for (let j = 0; j < importedNames.length; j++) { - newCodeOfNode += importedNames[j]; - codeAddToGlobalScope += this._addToGlobalThisCode( - localNames[j], - importedNames[j] - ); + // Handle aliased imports: import { foo as bar } -> const { foo: bar } + if (importedNames[j] !== localNames[j]) { + newCodeOfNode += `${importedNames[j]}: ${localNames[j]}`; + } else { + newCodeOfNode += importedNames[j]; + } + // Use local name for globalThis assignment since that's what's in scope + codeAddToGlobalScope += this._addToGlobalThisCode(localNames[j]); if (j < importedNames.length - 1) { newCodeOfNode += ', '; } From 386cd25e7edf5054b796df46a7a9d04a761b06e4 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Fri, 20 Feb 2026 09:03:36 +0100 Subject: [PATCH 11/20] iframe + web worker --- README.md | 109 ++++ examples/magic-imports.ipynb | 27 +- examples/rich-output.ipynb | 51 +- .../javascript-kernel-extension/src/index.ts | 94 ++- packages/javascript-kernel/src/executor.ts | 339 ++++++++--- packages/javascript-kernel/src/index.ts | 3 + packages/javascript-kernel/src/kernel.ts | 514 ++++++---------- .../javascript-kernel/src/runtime_backends.ts | 571 ++++++++++++++++++ .../src/runtime_evaluator.ts | 299 +++++++++ .../javascript-kernel/src/runtime_protocol.ts | 160 +++++ .../javascript-kernel/src/worker-runtime.ts | 175 ++++++ 11 files changed, 1885 insertions(+), 457 deletions(-) create mode 100644 packages/javascript-kernel/src/runtime_backends.ts create mode 100644 packages/javascript-kernel/src/runtime_evaluator.ts create mode 100644 packages/javascript-kernel/src/runtime_protocol.ts create mode 100644 packages/javascript-kernel/src/worker-runtime.ts diff --git a/README.md b/README.md index 4851b03..a38c610 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`: + Runs code in a sandboxed `iframe`. Use this when your code needs browser DOM APIs like `document`, `window`, or canvas access through the page context. +- `JavaScript (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 (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 mode), 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/magic-imports.ipynb b/examples/magic-imports.ipynb index 78ac97e..8543876 100644 --- a/examples/magic-imports.ipynb +++ b/examples/magic-imports.ipynb @@ -15,7 +15,9 @@ "source": [ "## Importing npm Packages\n", "\n", - "Just use standard ES module import syntax with a package name:" + "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" ] }, { @@ -26,12 +28,27 @@ "source": [ "import confetti from 'canvas-confetti';\n", "\n", - "// Fire some confetti!\n", - "confetti({\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" ] }, { @@ -218,4 +235,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} \ No newline at end of file +} diff --git a/examples/rich-output.ipynb b/examples/rich-output.ipynb index e8daff9..762e843 100644 --- a/examples/rich-output.ipynb +++ b/examples/rich-output.ipynb @@ -237,6 +237,55 @@ "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": {}, @@ -361,4 +410,4 @@ }, "nbformat": 4, "nbformat_minor": 4 -} \ No newline at end of file +} diff --git a/packages/javascript-kernel-extension/src/index.ts b/packages/javascript-kernel-extension/src/index.ts index 029a3bd..e1e71e8 100644 --- a/packages/javascript-kernel-extension/src/index.ts +++ b/packages/javascript-kernel-extension/src/index.ts @@ -11,45 +11,91 @@ 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', - language: 'javascript', argv: [], - spec: { - argv: [], - env: {}, - display_name: 'JavaScript', - 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) => { + void app; + registerKernel(kernelspecs, { + name: 'javascript', + displayName: 'JavaScript', + 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) => { + void app; + registerKernel(kernelspecs, { + name: 'javascript-worker', + displayName: 'JavaScript (Worker)', + runtime: 'worker' }); } }; -const plugins: JupyterFrontEndPlugin[] = [kernel]; +const plugins: JupyterFrontEndPlugin[] = [kernelIFrame, kernelWorker]; export default plugins; diff --git a/packages/javascript-kernel/src/executor.ts b/packages/javascript-kernel/src/executor.ts index 2fac8c7..a9f4e0b 100644 --- a/packages/javascript-kernel/src/executor.ts +++ b/packages/javascript-kernel/src/executor.ts @@ -46,6 +46,8 @@ export interface IImportInfo { namedImports: Record; } +type JSCallable = (...args: any[]) => any; + /** * Result of code completion. */ @@ -120,10 +122,10 @@ export class JavaScriptExecutor { /** * Instantiate a new JavaScriptExecutor. * - * @param globalScope - The global scope (window) for code execution. + * @param globalScope - The global scope (globalThis) for code execution. * @param config - Optional executor configuration. */ - constructor(globalScope: Window, config?: ExecutorConfig) { + constructor(globalScope: Record, config?: ExecutorConfig) { this._globalScope = globalScope; this._config = config || new ExecutorConfig(); } @@ -166,17 +168,12 @@ export class JavaScriptExecutor { ${extraReturnCode} `; - // Inject built-in functions from 'this' (the iframe window when called) - // This is needed because new Function() scopes to parent window - const builtinsCode = 'const { display, console } = this;'; - - const asyncFunction = new Function(` - const afunc = async function() { - ${builtinsCode} + const asyncFunctionFactory = this._createScopedFunction(` + return async function() { ${combinedCode} }; - return afunc; - `)(); + `) as () => () => Promise; + const asyncFunction = asyncFunctionFactory.call(this._globalScope); return { asyncFunction, @@ -241,7 +238,7 @@ export class JavaScriptExecutor { } /** - * Generate async JavaScript code to load imports and assign them to window. + * 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. @@ -255,19 +252,21 @@ export class JavaScriptExecutor { 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 import("${imp.url}");` + `const { default: ${imp.defaultImport} } = await ${importCall};` + ); + lines.push( + `globalThis["${imp.defaultImport}"] = ${imp.defaultImport};` ); - lines.push(`window["${imp.defaultImport}"] = ${imp.defaultImport};`); } if (imp.namespaceImport) { + lines.push(`const ${imp.namespaceImport} = await ${importCall};`); lines.push( - `const ${imp.namespaceImport} = await import("${imp.url}");` - ); - lines.push( - `window["${imp.namespaceImport}"] = ${imp.namespaceImport};` + `globalThis["${imp.namespaceImport}"] = ${imp.namespaceImport};` ); } @@ -278,10 +277,10 @@ export class JavaScriptExecutor { k === imp.namedImports[k] ? k : `${k}: ${imp.namedImports[k]}` ) .join(', '); - lines.push(`const { ${destructure} } = await import("${imp.url}");`); + lines.push(`const { ${destructure} } = await ${importCall};`); for (const importedName of namedKeys) { const localName = imp.namedImports[importedName]; - lines.push(`window["${localName}"] = ${localName};`); + lines.push(`globalThis["${localName}"] = ${localName};`); } } @@ -291,7 +290,7 @@ export class JavaScriptExecutor { !imp.namespaceImport && Object.keys(imp.namedImports).length === 0 ) { - lines.push(`await import("${imp.url}");`); + lines.push(`await ${importCall};`); } } @@ -549,53 +548,87 @@ export class JavaScriptExecutor { } // Handle Error objects - if (value instanceof Error) { + if ( + this._isInstanceOfRealm( + value, + 'Error', + typeof Error === 'undefined' ? undefined : Error + ) + ) { + const errorValue = value as Error; return { - 'text/plain': value.stack || value.toString(), + 'text/plain': errorValue.stack || errorValue.toString(), 'application/json': { - name: value.name, - message: value.message, - stack: value.stack + name: errorValue.name, + message: errorValue.message, + stack: errorValue.stack } }; } // Handle Date objects - if (value instanceof Date) { + if ( + this._isInstanceOfRealm( + value, + 'Date', + typeof Date === 'undefined' ? undefined : Date + ) + ) { + const dateValue = value as Date; return { - 'text/plain': value.toISOString(), - 'application/json': value.toISOString() + 'text/plain': dateValue.toISOString(), + 'application/json': dateValue.toISOString() }; } // Handle RegExp objects - if (value instanceof RegExp) { - return { 'text/plain': value.toString() }; + if ( + this._isInstanceOfRealm( + value, + 'RegExp', + typeof RegExp === 'undefined' ? undefined : RegExp + ) + ) { + return { 'text/plain': (value as RegExp).toString() }; } // Handle Map - if (value instanceof Map) { - const entries = Array.from(value.entries()); + if ( + this._isInstanceOfRealm( + value, + 'Map', + typeof Map === 'undefined' ? undefined : Map + ) + ) { + const mapValue = value as Map; + const entries = Array.from(mapValue.entries()); try { return { - 'text/plain': `Map(${value.size}) { ${entries.map(([k, v]) => `${String(k)} => ${String(v)}`).join(', ')} }`, + 'text/plain': `Map(${mapValue.size}) { ${entries.map(([k, v]) => `${String(k)} => ${String(v)}`).join(', ')} }`, 'application/json': Object.fromEntries(entries) }; } catch { - return { 'text/plain': `Map(${value.size})` }; + return { 'text/plain': `Map(${mapValue.size})` }; } } // Handle Set - if (value instanceof Set) { - const items = Array.from(value); + if ( + this._isInstanceOfRealm( + value, + 'Set', + typeof Set === 'undefined' ? undefined : Set + ) + ) { + const setValue = value as Set; + const items = Array.from(setValue); try { return { - 'text/plain': `Set(${value.size}) { ${items.map(v => String(v)).join(', ')} }`, + 'text/plain': `Set(${setValue.size}) { ${items.map(v => String(v)).join(', ')} }`, 'application/json': items }; } catch { - return { 'text/plain': `Set(${value.size})` }; + return { 'text/plain': `Set(${setValue.size})` }; } } @@ -626,7 +659,13 @@ export class JavaScriptExecutor { } // Handle Promise (show as pending) - if (value instanceof Promise) { + if ( + this._isInstanceOfRealm( + value, + 'Promise', + typeof Promise === 'undefined' ? undefined : Promise + ) + ) { return { 'text/plain': 'Promise { }' }; } @@ -707,10 +746,10 @@ export class JavaScriptExecutor { let rootObject = globalScope; if (rootObjectStr !== '') { try { - const evalFunc = new Function( + const evalFunc = this._createScopedFunction( 'scope', `with(scope) { return ${rootObjectStr}; }` - ); + ) as (scope: any) => any; rootObject = evalFunc(globalScope); } catch { return { @@ -880,10 +919,10 @@ export class JavaScriptExecutor { try { // Try to evaluate the expression in the global scope - const evalFunc = new Function( + const evalFunc = this._createScopedFunction( 'scope', `with(scope) { return ${expression}; }` - ); + ) as (scope: any) => any; const value = evalFunc(this._globalScope); // Build inspection data @@ -989,7 +1028,21 @@ export class JavaScriptExecutor { * Add code to export top-level variables to global scope. */ private _addToGlobalThisCode(key: string, identifier = key): string { - return `globalThis["${key}"] = ${identifier};`; + // 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; } /** @@ -1013,37 +1066,18 @@ export class JavaScriptExecutor { for (const node of ast.body) { if (node.type === 'FunctionDeclaration') { const name = node.id.name; - extraCode.push(`globalThis["${name}"] = ${name};`); + extraCode.push(this._addToGlobalThisCode(name)); } else if (node.type === 'ClassDeclaration') { const name = node.id.name; - extraCode.push(`globalThis["${name}"] = ${name};`); + extraCode.push(this._addToGlobalThisCode(name)); } else if (node.type === 'VariableDeclaration') { const declarations = node.declarations; for (const declaration of declarations) { - const declarationType = declaration.id.type; - - if (declarationType === 'ObjectPattern') { - // Handle object destructuring: const { a, b } = obj or const { a: b } = obj - for (const prop of declaration.id.properties) { - // For { a: b }, key is 'a' but local variable is 'b' - // For { a }, key and value are both 'a' - const localName = - prop.value?.type === 'Identifier' - ? prop.value.name - : prop.key.name; - extraCode.push(this._addToGlobalThisCode(localName)); - } - } else if (declarationType === 'ArrayPattern') { - // Handle array destructuring: const [a, b] = arr - const keys = declaration.id.elements - .filter((el: any) => el !== null) - .map((element: any) => element.name); - for (const key of keys) { - extraCode.push(this._addToGlobalThisCode(key)); - } - } else if (declarationType === 'Identifier') { - extraCode.push(this._addToGlobalThisCode(declaration.id.name)); + const identifiers: string[] = []; + this._collectDeclaredIdentifiers(declaration.id, identifiers); + for (const name of identifiers) { + extraCode.push(this._addToGlobalThisCode(name)); } } } @@ -1052,6 +1086,46 @@ export class JavaScriptExecutor { 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. */ @@ -1100,7 +1174,7 @@ export class JavaScriptExecutor { lastNodeExprStart, lastNodeExprEnd ); - const extraReturnCode = `return [${codeOfLastNode}];`; + const extraReturnCode = `return ${codeOfLastNode};`; return { withReturn: true, @@ -1171,6 +1245,7 @@ export class JavaScriptExecutor { 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' @@ -1178,7 +1253,7 @@ export class JavaScriptExecutor { modifiedUserCode, node.start, node.end, - `await import("${importSource}");\n` + `await import(${importSourceCode});\n` ); } else { let hasDefaultImport = false; @@ -1207,36 +1282,39 @@ export class JavaScriptExecutor { } } - let newCodeOfNode = ''; + const importBinding = `__jsKernelImport${i}`; + let newCodeOfNode = `const ${importBinding} = await import(${importSourceCode});\n`; + const destructuredNames: string[] = []; if (hasDefaultImport) { - newCodeOfNode += `const { default: ${defaultImportName} } = await import("${importSource}");\n`; + destructuredNames.push(`default: ${defaultImportName}`); codeAddToGlobalScope += this._addToGlobalThisCode(defaultImportName); } - if (hasNamespaceImport) { - newCodeOfNode += `const ${namespaceImportName} = await import("${importSource}");\n`; - codeAddToGlobalScope += - this._addToGlobalThisCode(namespaceImportName); - } - if (importedNames.length > 0) { - newCodeOfNode += 'const { '; for (let j = 0; j < importedNames.length; j++) { // Handle aliased imports: import { foo as bar } -> const { foo: bar } - if (importedNames[j] !== localNames[j]) { - newCodeOfNode += `${importedNames[j]}: ${localNames[j]}`; - } else { - newCodeOfNode += importedNames[j]; - } + 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 (j < importedNames.length - 1) { - newCodeOfNode += ', '; - } } - newCodeOfNode += ` } = await import("${importSource}");\n`; + } + + if (destructuredNames.length > 0) { + newCodeOfNode += `const { ${destructuredNames.join( + ', ' + )} } = ${importBinding};\n`; + } + + if (hasNamespaceImport) { + newCodeOfNode += `const ${namespaceImportName} = ${importBinding};\n`; + codeAddToGlobalScope += + this._addToGlobalThisCode(namespaceImportName); } modifiedUserCode = this._replaceCode( @@ -1385,34 +1463,97 @@ export class JavaScriptExecutor { * Check if value is a DOM element. */ private _isDOMElement(value: any): boolean { - return typeof HTMLElement !== 'undefined' && value instanceof HTMLElement; + return ( + this._isInstanceOfRealm( + value, + 'HTMLElement', + typeof HTMLElement === 'undefined' ? undefined : HTMLElement + ) || + this._isInstanceOfRealm( + value, + 'SVGElement', + typeof SVGElement === 'undefined' ? undefined : SVGElement + ) + ); } /** * Get MIME bundle for DOM elements. */ - private _getDOMElementMimeBundle(element: HTMLElement): IMimeBundle { + private _getDOMElementMimeBundle(element: any): IMimeBundle { + const isCanvasElement = + this._isInstanceOfRealm( + element, + 'HTMLCanvasElement', + typeof HTMLCanvasElement === 'undefined' ? undefined : HTMLCanvasElement + ) || + (typeof element?.toDataURL === 'function' && + typeof element?.getContext === 'function'); + // For canvas elements, try to get image data - if (element instanceof HTMLCanvasElement) { + if (isCanvasElement) { + const canvas = element as HTMLCanvasElement; try { - const dataUrl = element.toDataURL('image/png'); + const dataUrl = canvas.toDataURL('image/png'); const base64 = dataUrl.split(',')[1]; return { 'image/png': base64, - 'text/plain': `` + 'text/plain': `` }; } catch { - return { 'text/plain': element.outerHTML }; + const canvasHtml = + typeof canvas.outerHTML === 'string' + ? canvas.outerHTML + : ''; + return { 'text/plain': canvasHtml }; } } // For other elements, return HTML + const elementHtml = + typeof element?.outerHTML === 'string' + ? element.outerHTML + : String(element); return { - 'text/html': element.outerHTML, - 'text/plain': element.outerHTML + 'text/html': elementHtml, + 'text/plain': elementHtml }; } + /** + * Check `instanceof` against runtime-realm constructors when available. + */ + private _isInstanceOfRealm( + value: any, + ctorName: string, + fallbackCtor?: any + ): boolean { + if (value === null || value === undefined) { + return false; + } + + const scopeCtor = this._globalScope?.[ctorName]; + if (typeof scopeCtor === 'function') { + try { + if (value instanceof scopeCtor) { + return true; + } + } catch { + // Ignore invalid instanceof checks. + } + } + + if (typeof fallbackCtor === 'function') { + try { + return value instanceof fallbackCtor; + } catch { + return false; + } + } + + return false; + } + /** * Format array preview with truncation. */ @@ -1821,5 +1962,5 @@ export class JavaScriptExecutor { } private _config: ExecutorConfig; - private _globalScope: Window; + private _globalScope: Record; } diff --git a/packages/javascript-kernel/src/index.ts b/packages/javascript-kernel/src/index.ts index ce3f2fa..c421980 100644 --- a/packages/javascript-kernel/src/index.ts +++ b/packages/javascript-kernel/src/index.ts @@ -4,3 +4,6 @@ export * from './kernel'; export * from './executor'; export * from './display'; +export * from './runtime_protocol'; +export * from './runtime_backends'; +export * from './runtime_evaluator'; diff --git a/packages/javascript-kernel/src/kernel.ts b/packages/javascript-kernel/src/kernel.ts index 0579082..cea7241 100644 --- a/packages/javascript-kernel/src/kernel.ts +++ b/packages/javascript-kernel/src/kernel.ts @@ -5,12 +5,16 @@ import type { KernelMessage } from '@jupyterlab/services'; import { BaseKernel, type IKernel } from '@jupyterlite/services'; -import { PromiseDelegate } from '@lumino/coreutils'; - -import { JavaScriptExecutor } from './executor'; +import type { JavaScriptExecutor } from './executor'; +import { + IFrameRuntimeBackend, + IRuntimeBackend, + WorkerRuntimeBackend +} from './runtime_backends'; +import type { RuntimeMode, RuntimeOutputMessage } from './runtime_protocol'; /** - * A kernel that executes JavaScript code in an IFrame. + * A kernel that executes JavaScript code in browser runtimes. */ export class JavaScriptKernel extends BaseKernel implements IKernel { /** @@ -20,7 +24,9 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { */ constructor(options: JavaScriptKernel.IOptions) { super(options); - this.initIFrame(); + this._runtimeMode = options.runtime ?? 'iframe'; + this._executorFactory = options.executorFactory; + this._backend = this.createBackend(this._runtimeMode); } /** @@ -30,37 +36,32 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { if (this.isDisposed) { return; } - this.cleanupIFrame(); + + this._backend.dispose(); super.dispose(); } /** - * A promise that is fulfilled when the kernel is ready. + * A promise that is fulfilled when the kernel runtime is ready. */ get ready(): Promise { - return this._ready.promise; - } - - /** - * Get the executor instance. - * Subclasses can use this to access executor functionality. - */ - protected get executor(): JavaScriptExecutor | undefined { - return this._executor; + return this._backend.ready; } /** - * Get the iframe element. - * Subclasses can use this for custom iframe operations. + * The active runtime backend. */ - protected get iframe(): HTMLIFrameElement { - return this._iframe; + protected get runtimeBackend(): IRuntimeBackend { + return this._backend; } /** * Handle a kernel_info_request message. */ async kernelInfoRequest(): Promise { + const runtimeName = + this._runtimeMode === 'worker' ? 'Web Worker' : 'IFrame'; + const content: KernelMessage.IInfoReply = { implementation: 'JavaScript', implementation_version: '0.1.0', @@ -77,7 +78,7 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { }, protocol_version: '5.3', status: 'ok', - banner: 'A JavaScript kernel running in the browser', + banner: `A JavaScript kernel running in the browser (${runtimeName})`, help_links: [ { text: 'JavaScript Kernel', @@ -85,94 +86,51 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { } ] }; + return content; } /** * Handle an `execute_request` message. - * - * @param content - The content of the request. */ async executeRequest( content: KernelMessage.IExecuteRequestMsg['content'] ): Promise { - const { code } = content; - - if (!this._executor) { - return { - status: 'error', - execution_count: this.executionCount, - ename: 'ExecutorError', - evalue: 'Executor not initialized', - traceback: [] - }; - } - try { - // Use the executor to create an async function from the code - const { asyncFunction, withReturn } = - this._executor.makeAsyncFromCode(code); - - // Execute the async function in the iframe context - const resultPromise = this._evalFunc( - this._iframe.contentWindow, - asyncFunction - ); - - if (withReturn) { - const resultHolder = await resultPromise; - const result = resultHolder[0]; - // Skip undefined results (e.g., from console.log) - if (result !== undefined) { - const data = this._executor.getMimeBundle(result); - - this.publishExecuteResult({ - execution_count: this.executionCount, - data, - metadata: {} - }); - } - } else { - await resultPromise; - } - - return { - status: 'ok', - execution_count: this.executionCount, - user_expressions: {} - }; - } catch (e) { - const error = e as Error; - const { name, message } = error; - - // Use executor to clean stack trace - const cleanedStack = this._executor.cleanStackTrace(error); + await this.ready; + return await this._backend.execute(content.code, this.executionCount); + } catch (error) { + const normalized = this.normalizeError(error); + const traceback = [ + normalized.stack || normalized.message || String(error) + ]; this.publishExecuteError({ - ename: name || 'Error', - evalue: message || '', - traceback: [cleanedStack] + ename: normalized.name || 'RuntimeError', + evalue: normalized.message || '', + traceback }); return { status: 'error', execution_count: this.executionCount, - ename: name || 'Error', - evalue: message || '', - traceback: [cleanedStack] + ename: normalized.name || 'RuntimeError', + evalue: normalized.message || '', + traceback }; } } /** - * Handle a complete_request message. - * - * @param content - The content of the request. + * Handle a `complete_request` message. */ async completeRequest( content: KernelMessage.ICompleteRequestMsg['content'] ): Promise { - if (!this._executor) { + try { + await this.ready; + return await this._backend.complete(content.code, content.cursor_pos); + } catch { return { matches: [], cursor_start: content.cursor_pos, @@ -181,30 +139,22 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { status: 'ok' }; } - - const { code, cursor_pos } = content; - const result = this._executor.completeRequest(code, cursor_pos); - - return { - matches: result.matches, - cursor_start: result.cursorStart, - cursor_end: result.cursorEnd || cursor_pos, - metadata: {}, - status: 'ok' - }; } /** * Handle an `inspect_request` message. - * - * @param content - The content of the request. - * - * @returns A promise that resolves with the response message. */ async inspectRequest( content: KernelMessage.IInspectRequestMsg['content'] ): Promise { - if (!this._executor) { + try { + await this.ready; + return await this._backend.inspect( + content.code, + content.cursor_pos, + content.detail_level + ); + } catch { return { status: 'ok', found: false, @@ -212,49 +162,26 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { metadata: {} }; } - - const { code, cursor_pos, detail_level } = content; - const result = this._executor.inspect(code, cursor_pos, detail_level); - - return { - status: 'ok', - found: result.found, - data: result.data, - metadata: result.metadata - }; } /** * Handle an `is_complete_request` message. - * - * @param content - The content of the request. - * - * @returns A promise that resolves with the response message. */ async isCompleteRequest( content: KernelMessage.IIsCompleteRequestMsg['content'] ): Promise { - if (!this._executor) { + try { + await this.ready; + return await this._backend.isComplete(content.code); + } catch { return { status: 'unknown' }; } - - const { code } = content; - const result = this._executor.isComplete(code); - - return { - status: result.status, - indent: result.indent || '' - }; } /** * Handle a `comm_info_request` message. - * - * @param content - The content of the request. - * - * @returns A promise that resolves with the response message. */ async commInfoRequest( content: KernelMessage.ICommInfoRequestMsg['content'] @@ -267,8 +194,6 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { /** * Send an `input_reply` message. - * - * @param content - The content of the reply. */ inputReply(content: KernelMessage.IInputReplyMsg['content']): void { throw new Error('Not implemented'); @@ -276,8 +201,6 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { /** * Send an `comm_open` message. - * - * @param msg - The comm_open message. */ async commOpen(msg: KernelMessage.ICommOpenMsg): Promise { throw new Error('Not implemented'); @@ -285,8 +208,6 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { /** * Send an `comm_msg` message. - * - * @param msg - The comm_msg message. */ async commMsg(msg: KernelMessage.ICommMsgMsg): Promise { throw new Error('Not implemented'); @@ -294,249 +215,186 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { /** * Send an `comm_close` message. - * - * @param close - The comm_close message. */ async commClose(msg: KernelMessage.ICommCloseMsg): Promise { throw new Error('Not implemented'); } /** - * Execute code in the kernel IFrame. - * - * @param code - The code to execute. + * Called once a runtime backend is initialized, before `ready` resolves. */ - protected evaluate(code: string): any { - return this._evalCodeFunc(this._iframe.contentWindow, code); + protected async onRuntimeReady( + _context: JavaScriptKernel.IRuntimeReadyContext + ): Promise { + return Promise.resolve(); } /** - * Initialize the IFrame and set up communication. + * Create a runtime backend for the selected mode. */ - protected async initIFrame(): Promise { - this._container = document.createElement('div'); - this._container.style.cssText = - 'position:absolute;width:0;height:0;overflow:hidden;'; - document.body.appendChild(this._container); - - // Create the iframe with sandbox permissions - this._iframe = document.createElement('iframe'); - this._iframe.sandbox.add('allow-scripts', 'allow-same-origin'); - this._iframe.style.cssText = 'border:none;width:100%;height:100%;'; - - this._iframe.srcdoc = ` - - - - JavaScript Kernel - - -`; - - this._container.appendChild(this._iframe); - - // Wait for iframe to load - await new Promise(resolve => { - this._iframe.onload = () => resolve(); - }); - - // Set up console overrides in the iframe - this.setupConsoleOverrides(); - - // Set up message handling for console output - this._messageHandler = (event: MessageEvent) => { - if (event.source === this._iframe.contentWindow) { - this.processMessage(event.data); + protected createBackend(runtimeMode: RuntimeMode): IRuntimeBackend { + const options = { + onOutput: (message: RuntimeOutputMessage) => { + this.processRuntimeMessage(message); } }; - window.addEventListener('message', this._messageHandler); - // Initialize the executor with the iframe's window - if (this._iframe.contentWindow) { - this._executor = new JavaScriptExecutor(this._iframe.contentWindow); - this.setupDisplay(); + if (runtimeMode === 'worker') { + return new WorkerRuntimeBackend({ + ...options, + onReady: async context => { + await this.onRuntimeReady({ + runtime: 'worker', + execute: async code => { + const reply = await context.execute(code); + if (reply.status === 'error') { + throw this.createRuntimeInitializationError(reply); + } + return reply; + } + }); + } + }); } - this._ready.resolve(); + return new IFrameRuntimeBackend({ + ...options, + executorFactory: this._executorFactory, + onReady: async context => { + await this.onRuntimeReady({ + runtime: 'iframe', + globalScope: context.globalScope, + executor: context.evaluator.executor, + execute: async code => Promise.resolve(context.evaluate(code)) + }); + } + }); } /** - * Set up the display() function in the iframe. + * Route runtime output messages to Jupyter kernel channels. */ - protected setupDisplay(): void { - if (!this._iframe.contentWindow || !this._executor) { - return; - } - - const executor = this._executor; - - // Create display function that uses executor's getMimeBundle - // and calls kernel's displayData directly - const display = (obj: any, metadata?: Record) => { - const data = executor.getMimeBundle(obj); - this.displayData( - { data, metadata: metadata ?? {}, transient: {} }, - this.parentHeader - ); - }; + protected processRuntimeMessage(message: RuntimeOutputMessage): void { + const parentHeader = this.parentHeader; - // Expose display in the iframe's global scope - (this._iframe.contentWindow as any).display = display; + switch (message.type) { + case 'stream': + this.stream(message.bundle, parentHeader); + break; + case 'input_request': + this.inputRequest(message.content, parentHeader); + break; + case 'display_data': + this.displayData(message.bundle, parentHeader); + break; + case 'update_display_data': + this.updateDisplayData(message.bundle, parentHeader); + break; + case 'clear_output': + this.clearOutput(message.bundle, parentHeader); + break; + case 'execute_result': + this.publishExecuteResult(message.bundle, parentHeader); + break; + case 'execute_error': + this.publishExecuteError(message.bundle, parentHeader); + break; + default: + break; + } } /** - * Set up console overrides in the iframe to bubble output to parent. + * Normalize unknown thrown values into Error instances. */ - protected setupConsoleOverrides(): void { - if (!this._iframe.contentWindow) { - return; + protected normalizeError(error: unknown): Error { + if (error instanceof Error) { + return error; } - this._evalCodeFunc( - this._iframe.contentWindow, - ` - console._log = console.log; - console._error = console.error; - window._bubbleUp = function(msg) { - window.parent.postMessage(msg, '*'); - }; - console.log = function() { - const args = Array.prototype.slice.call(arguments); - window._bubbleUp({ - type: 'stream', - bundle: { name: 'stdout', text: args.join(' ') + '\\n' } - }); - }; - console.info = console.log; - console.error = function() { - const args = Array.prototype.slice.call(arguments); - window._bubbleUp({ - type: 'stream', - bundle: { name: 'stderr', text: args.join(' ') + '\\n' } - }); - }; - console.warn = console.error; - window.onerror = function(message, source, lineno, colno, error) { - console.error(message); - }; - ` - ); + return new Error(String(error)); } /** - * Clean up the iframe resources. + * Normalize an execute reply error into an Error instance. */ - protected cleanupIFrame(): void { - if (this._messageHandler) { - window.removeEventListener('message', this._messageHandler); - this._messageHandler = null; - } - this._iframe.remove(); - if (this._container) { - this._container.remove(); - this._container = null; + private createRuntimeInitializationError( + reply: KernelMessage.IExecuteReplyMsg['content'] + ): Error { + const ename = + 'ename' in reply && typeof reply.ename === 'string' + ? reply.ename + : 'RuntimeError'; + const evalue = + 'evalue' in reply && typeof reply.evalue === 'string' + ? reply.evalue + : 'Runtime initialization failed'; + const error = new Error(evalue); + error.name = ename; + + const traceback = + 'traceback' in reply && Array.isArray(reply.traceback) + ? reply.traceback + : []; + if (traceback.length > 0) { + error.stack = traceback.join('\n'); } + + return error; } + private _backend: IRuntimeBackend; + private _executorFactory?: JavaScriptKernel.IExecutorFactory; + private _runtimeMode: RuntimeMode; +} + +/** + * A namespace for JavaScriptKernel statics. + */ +export namespace JavaScriptKernel { /** - * Process a message coming from the IFrame. - * - * @param msg - The message to process. + * Runtime context shared by all backend initialization hooks. */ - protected processMessage(msg: any): void { - if (!msg || !msg.type) { - return; - } + export interface IRuntimeReadyContextBase { + runtime: RuntimeMode; + execute: (code: string) => Promise; + } - const parentHeader = this.parentHeader; + /** + * Runtime context for iframe backend initialization. + */ + export interface IIFrameRuntimeReadyContext extends IRuntimeReadyContextBase { + runtime: 'iframe'; + globalScope: Record; + executor: JavaScriptExecutor; + } - switch (msg.type) { - case 'stream': { - const bundle = msg.bundle ?? { name: 'stdout', text: '' }; - this.stream(bundle, parentHeader); - break; - } - case 'input_request': { - const bundle = msg.content ?? { prompt: '', password: false }; - this.inputRequest(bundle, parentHeader); - break; - } - case 'display_data': { - const bundle = msg.bundle ?? { data: {}, metadata: {}, transient: {} }; - this.displayData(bundle, parentHeader); - break; - } - case 'update_display_data': { - const bundle = msg.bundle ?? { data: {}, metadata: {}, transient: {} }; - this.updateDisplayData(bundle, parentHeader); - break; - } - case 'clear_output': { - const bundle = msg.bundle ?? { wait: false }; - this.clearOutput(bundle, parentHeader); - break; - } - case 'execute_result': { - const bundle = msg.bundle ?? { - execution_count: 0, - data: {}, - metadata: {} - }; - this.publishExecuteResult(bundle, parentHeader); - break; - } - case 'execute_error': { - const bundle = msg.bundle ?? { ename: '', evalue: '', traceback: [] }; - this.publishExecuteError(bundle, parentHeader); - break; - } - case 'comm_msg': - case 'comm_open': - case 'comm_close': { - this.handleComm( - msg.type, - msg.content, - msg.metadata, - msg.buffers, - msg.parentHeader - ); - break; - } - } + /** + * Runtime context for worker backend initialization. + */ + export interface IWorkerRuntimeReadyContext extends IRuntimeReadyContextBase { + runtime: 'worker'; } /** - * Execute an async function in the iframe context. + * Runtime context available from `onRuntimeReady`. */ - private _evalFunc = (win: Window | null, asyncFunc: () => Promise) => { - if (!win) { - throw new Error('IFrame window not available'); - } - return asyncFunc.call(win); - }; + export type IRuntimeReadyContext = + | IIFrameRuntimeReadyContext + | IWorkerRuntimeReadyContext; /** - * Execute raw code string in the iframe context. + * Factory used to customize the iframe runtime executor. */ - private _evalCodeFunc = new Function( - 'window', - 'code', - 'return window.eval(code);' - ) as (win: Window | null, code: string) => any; - - private _iframe!: HTMLIFrameElement; - private _container: HTMLDivElement | null = null; - private _messageHandler: ((event: MessageEvent) => void) | null = null; - private _executor?: JavaScriptExecutor; - private _ready = new PromiseDelegate(); -} + export type IExecutorFactory = ( + globalScope: Record + ) => JavaScriptExecutor; -/** - * A namespace for JavaScriptKernel statics - */ -export namespace JavaScriptKernel { /** * The instantiation options for a JavaScript kernel. */ - export interface IOptions extends IKernel.IOptions {} + export interface IOptions extends IKernel.IOptions { + runtime?: RuntimeMode; + executorFactory?: IExecutorFactory; + } } diff --git a/packages/javascript-kernel/src/runtime_backends.ts b/packages/javascript-kernel/src/runtime_backends.ts new file mode 100644 index 0000000..0a02645 --- /dev/null +++ b/packages/javascript-kernel/src/runtime_backends.ts @@ -0,0 +1,571 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import type { KernelMessage } from '@jupyterlab/services'; + +import { PromiseDelegate } from '@lumino/coreutils'; + +import type { JavaScriptExecutor } from './executor'; +import { JavaScriptRuntimeEvaluator } from './runtime_evaluator'; +import type { + RuntimeOutputHandler, + RuntimeRequest, + RuntimeResponse, + WorkerRuntimeInboundMessage, + WorkerRuntimeOutboundMessage +} from './runtime_protocol'; + +/** + * Shared options for runtime backend implementations. + */ +export interface IRuntimeBackendOptions { + onOutput: RuntimeOutputHandler; +} + +/** + * Interface implemented by all execution runtime backends. + */ +export interface IRuntimeBackend { + readonly ready: Promise; + dispose(): void; + execute( + code: string, + executionCount: number + ): Promise; + complete( + code: string, + cursorPos: number + ): Promise; + inspect( + code: string, + cursorPos: number, + detailLevel: KernelMessage.IInspectRequestMsg['content']['detail_level'] + ): Promise; + isComplete( + code: string + ): Promise; +} + +/** + * Runtime backend that executes code in a hidden iframe. + */ +export class IFrameRuntimeBackend implements IRuntimeBackend { + /** + * Instantiate a new iframe runtime backend. + */ + constructor(options: IFrameRuntimeBackend.IOptions) { + this._options = options; + void this._init(); + } + + /** + * A promise that resolves when the runtime is initialized. + */ + get ready(): Promise { + return this._ready.promise; + } + + /** + * The iframe used by the runtime backend. + */ + get iframe(): HTMLIFrameElement | null { + return this._iframe; + } + + /** + * The runtime global scope. + */ + get globalScope(): Record | null { + return this._iframe?.contentWindow + ? (this._iframe.contentWindow as Record) + : null; + } + + /** + * The runtime evaluator. + */ + get evaluator(): JavaScriptRuntimeEvaluator | null { + return this._evaluator; + } + + /** + * Dispose iframe resources. + */ + dispose(): void { + this._ready.reject(new Error('IFrame runtime disposed')); + this._evaluator?.dispose(); + this._evaluator = null; + + this._iframe?.remove(); + this._iframe = null; + + if (this._container) { + this._container.remove(); + this._container = null; + } + } + + /** + * Execute code inside the iframe runtime. + */ + async execute( + code: string, + executionCount: number + ): Promise { + await this.ready; + return this._getEvaluator().execute(code, executionCount); + } + + /** + * Complete code inside the iframe runtime. + */ + async complete( + code: string, + cursorPos: number + ): Promise { + await this.ready; + return this._getEvaluator().complete(code, cursorPos); + } + + /** + * Inspect code inside the iframe runtime. + */ + async inspect( + code: string, + cursorPos: number, + detailLevel: KernelMessage.IInspectRequestMsg['content']['detail_level'] + ): Promise { + await this.ready; + return this._getEvaluator().inspect(code, cursorPos, detailLevel); + } + + /** + * Check code completeness inside the iframe runtime. + */ + async isComplete( + code: string + ): Promise { + await this.ready; + return this._getEvaluator().isComplete(code); + } + + /** + * Evaluate raw code in the iframe global scope. + */ + evaluate(code: string): any { + const globalScope = this._getGlobalScope(); + const scopeFunction = globalScope.Function; + const functionConstructor = + typeof scopeFunction === 'function' + ? (scopeFunction as FunctionConstructor) + : Function; + const evaluateCode = functionConstructor(code); + return evaluateCode.call(globalScope); + } + + /** + * Initialize iframe and evaluator. + */ + private async _init(): Promise { + try { + this._container = document.createElement('div'); + this._container.style.cssText = + 'position:absolute;width:0;height:0;overflow:hidden;'; + document.body.appendChild(this._container); + + this._iframe = document.createElement('iframe'); + this._iframe.sandbox.add('allow-scripts', 'allow-same-origin'); + this._iframe.style.cssText = 'border:none;width:100%;height:100%;'; + this._iframe.srcdoc = ` + + + + JavaScript Kernel + + +`; + + this._container.appendChild(this._iframe); + + await new Promise(resolve => { + if (!this._iframe) { + resolve(); + return; + } + this._iframe.onload = () => resolve(); + }); + + if (!this._iframe?.contentWindow) { + throw new Error('IFrame window not available'); + } + + const globalScope = this._iframe.contentWindow as Record; + const executor = this._options.executorFactory?.(globalScope); + this._evaluator = new JavaScriptRuntimeEvaluator({ + globalScope, + onOutput: this._options.onOutput, + executor + }); + + await this._options.onReady?.({ + iframe: this._iframe, + container: this._container, + globalScope, + evaluator: this._evaluator, + evaluate: code => this.evaluate(code) + }); + this._ready.resolve(); + } catch (error) { + this._evaluator?.dispose(); + this._evaluator = null; + this._iframe?.remove(); + this._iframe = null; + if (this._container) { + this._container.remove(); + this._container = null; + } + this._ready.reject(error); + } + } + + /** + * Return evaluator or throw when not initialized. + */ + private _getEvaluator(): JavaScriptRuntimeEvaluator { + if (!this._evaluator) { + throw new Error('IFrame runtime is not initialized'); + } + return this._evaluator; + } + + /** + * Return global scope or throw when not initialized. + */ + private _getGlobalScope(): Record { + const globalScope = this.globalScope; + if (!globalScope) { + throw new Error('IFrame runtime is not initialized'); + } + return globalScope; + } + + private _options: IFrameRuntimeBackend.IOptions; + private _ready = new PromiseDelegate(); + private _evaluator: JavaScriptRuntimeEvaluator | null = null; + private _iframe: HTMLIFrameElement | null = null; + private _container: HTMLDivElement | null = null; +} + +/** + * A namespace for IFrameRuntimeBackend statics. + */ +export namespace IFrameRuntimeBackend { + /** + * Runtime objects available after iframe initialization. + */ + export interface IReadyContext { + iframe: HTMLIFrameElement; + container: HTMLDivElement; + globalScope: Record; + evaluator: JavaScriptRuntimeEvaluator; + evaluate: (code: string) => any; + } + + /** + * The instantiation options for an iframe runtime backend. + */ + export interface IOptions extends IRuntimeBackendOptions { + executorFactory?: (globalScope: Record) => JavaScriptExecutor; + onReady?: (context: IReadyContext) => void | Promise; + } +} + +/** + * Runtime backend that executes code in a dedicated web worker. + */ +export class WorkerRuntimeBackend implements IRuntimeBackend { + /** + * Instantiate a new worker runtime backend. + */ + constructor(options: WorkerRuntimeBackend.IOptions) { + this._options = options; + + if (typeof Worker === 'undefined') { + this._ready.reject(new Error('Web Workers are not available')); + return; + } + + this._worker = new Worker(new URL('./worker-runtime.js', import.meta.url), { + type: 'module' + }); + this._worker.onmessage = event => { + this._onWorkerMessage(event.data as WorkerRuntimeOutboundMessage); + }; + this._worker.onerror = event => { + const details = [event.message || 'Worker runtime failed to initialize']; + if (event.filename) { + details.push(`at ${event.filename}:${event.lineno}:${event.colno}`); + } + this._handleWorkerFatal(new Error(details.join(' '))); + }; + this._worker.onmessageerror = () => { + this._handleWorkerFatal( + new Error( + 'Worker runtime sent a message that could not be deserialized' + ) + ); + }; + } + + /** + * A promise that resolves when the runtime is initialized. + */ + get ready(): Promise { + return this._ready.promise; + } + + /** + * Dispose worker resources. + */ + dispose(): void { + this._ready.reject(new Error('Worker runtime disposed')); + this._worker?.terminate(); + this._worker = null; + + this._rejectPending(new Error('Worker runtime disposed')); + } + + /** + * Execute code inside the worker runtime. + */ + async execute( + code: string, + executionCount: number + ): Promise { + return this._request({ + type: 'execute_request', + content: { + code + }, + execution_count: executionCount + }); + } + + /** + * Complete code inside the worker runtime. + */ + async complete( + code: string, + cursorPos: number + ): Promise { + return this._request({ + type: 'complete_request', + content: { + code, + cursor_pos: cursorPos + } + }); + } + + /** + * Inspect code inside the worker runtime. + */ + async inspect( + code: string, + cursorPos: number, + detailLevel: KernelMessage.IInspectRequestMsg['content']['detail_level'] + ): Promise { + return this._request({ + type: 'inspect_request', + content: { + code, + cursor_pos: cursorPos, + detail_level: detailLevel + } + }); + } + + /** + * Check code completeness inside the worker runtime. + */ + async isComplete( + code: string + ): Promise { + return this._request({ + type: 'is_complete_request', + content: { + code + } + }); + } + + /** + * Send a request to the worker and await response. + */ + private async _request(request: RuntimeRequest): Promise { + return this._requestWithMode(request, true); + } + + /** + * Send a request, optionally waiting for runtime readiness. + */ + private async _requestWithMode( + request: RuntimeRequest, + waitForReady: boolean + ): Promise { + if (waitForReady) { + await this.ready; + } + + if (!this._worker) { + throw new Error('Worker runtime is not initialized'); + } + + const id = this._nextRequestId++; + const envelope: WorkerRuntimeInboundMessage = { + kind: 'request', + id, + request + }; + + return new Promise((resolve, reject) => { + this._pending.set(id, { + resolve: value => resolve(value as T), + reject + }); + + try { + this._worker?.postMessage(envelope); + } catch (error) { + this._pending.delete(id); + reject(error as Error); + } + }); + } + + /** + * Handle worker output and request responses. + */ + private _onWorkerMessage(message: WorkerRuntimeOutboundMessage): void { + switch (message.kind) { + case 'ready': + void this._handleWorkerReady(); + break; + case 'output': + this._options.onOutput(message.message); + break; + case 'response': { + const pending = this._pending.get(message.id); + if (!pending) { + return; + } + this._pending.delete(message.id); + if (message.ok) { + pending.resolve(message.payload as RuntimeResponse); + } else { + const error = new Error(message.error.message); + error.name = message.error.name; + error.stack = message.error.stack; + pending.reject(error); + } + break; + } + default: + break; + } + } + + /** + * Resolve readiness after optional initialization hook. + */ + private async _handleWorkerReady(): Promise { + if (this._readyHandled) { + return; + } + this._readyHandled = true; + + try { + await this._options.onReady?.({ + execute: (code, executionCount = 0) => + this._requestWithMode( + { + type: 'execute_request', + content: { + code + }, + execution_count: executionCount + }, + false + ) + }); + this._ready.resolve(); + } catch (error) { + this._handleWorkerFatal(this._normalizeError(error)); + } + } + + /** + * Reject all pending requests and initialization with a fatal worker error. + */ + private _handleWorkerFatal(error: Error): void { + this._worker?.terminate(); + this._worker = null; + this._ready.reject(error); + this._rejectPending(error); + } + + /** + * Reject pending in-flight worker requests. + */ + private _rejectPending(error: Error): void { + for (const pending of this._pending.values()) { + pending.reject(error); + } + this._pending.clear(); + } + + /** + * Normalize unknown values to Error instances. + */ + private _normalizeError(error: unknown): Error { + if (error instanceof Error) { + return error; + } + return new Error(String(error)); + } + + private _options: WorkerRuntimeBackend.IOptions; + private _worker: Worker | null = null; + private _readyHandled = false; + private _nextRequestId = 1; + private _ready = new PromiseDelegate(); + private _pending = new Map< + number, + { + resolve: (value: RuntimeResponse) => void; + reject: (reason: Error) => void; + } + >(); +} + +/** + * A namespace for WorkerRuntimeBackend statics. + */ +export namespace WorkerRuntimeBackend { + /** + * Runtime capabilities available during worker initialization. + */ + export interface IReadyContext { + execute: ( + code: string, + executionCount?: number + ) => Promise; + } + + /** + * The instantiation options for a worker runtime backend. + */ + export interface IOptions extends IRuntimeBackendOptions { + onReady?: (context: IReadyContext) => void | Promise; + } +} diff --git a/packages/javascript-kernel/src/runtime_evaluator.ts b/packages/javascript-kernel/src/runtime_evaluator.ts new file mode 100644 index 0000000..c1d453b --- /dev/null +++ b/packages/javascript-kernel/src/runtime_evaluator.ts @@ -0,0 +1,299 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import type { KernelMessage } from '@jupyterlab/services'; + +import { JavaScriptExecutor } from './executor'; +import type { RuntimeOutputHandler } from './runtime_protocol'; + +/** + * Shared execution logic for iframe and worker runtime backends. + */ +export class JavaScriptRuntimeEvaluator { + /** + * Instantiate a runtime evaluator. + */ + constructor(options: JavaScriptRuntimeEvaluator.IOptions) { + this._globalScope = options.globalScope; + this._onOutput = options.onOutput; + this._executor = + options.executor ?? new JavaScriptExecutor(options.globalScope); + + this._setupDisplay(); + this._setupConsoleOverrides(); + } + + /** + * Dispose the evaluator and restore patched globals where possible. + */ + dispose(): void { + this._restoreConsoleOverrides(); + this._restoreDisplay(); + } + + /** + * The runtime global scope. + */ + get globalScope(): Record { + return this._globalScope; + } + + /** + * The executor used by the evaluator. + */ + get executor(): JavaScriptExecutor { + return this._executor; + } + + /** + * Execute user code in the configured runtime global scope. + */ + async execute( + code: string, + executionCount: number + ): Promise { + try { + const { asyncFunction, withReturn } = + this._executor.makeAsyncFromCode(code); + + const resultPromise = this._evalFunc(asyncFunction); + + if (withReturn) { + const result = await resultPromise; + + if (result !== undefined) { + const data = this._executor.getMimeBundle(result); + this._onOutput({ + type: 'execute_result', + bundle: { + execution_count: executionCount, + data, + metadata: {} + } + }); + } + } else { + await resultPromise; + } + + return { + status: 'ok', + execution_count: executionCount, + user_expressions: {} + }; + } catch (error) { + const normalized = this._normalizeError(error); + const cleanedStack = this._executor.cleanStackTrace(normalized); + + const content: KernelMessage.IReplyErrorContent = { + status: 'error', + ename: normalized.name || 'Error', + evalue: normalized.message || '', + traceback: [cleanedStack] + }; + + this._onOutput({ + type: 'execute_error', + bundle: content + }); + + return { + ...content, + execution_count: executionCount + }; + } + } + + /** + * Complete code at the given cursor position. + */ + complete( + code: string, + cursorPos: number + ): KernelMessage.ICompleteReplyMsg['content'] { + const result = this._executor.completeRequest(code, cursorPos); + + return { + matches: result.matches, + cursor_start: result.cursorStart, + cursor_end: result.cursorEnd || cursorPos, + metadata: {}, + status: 'ok' + }; + } + + /** + * Inspect symbol information at the given cursor position. + */ + inspect( + code: string, + cursorPos: number, + detailLevel: KernelMessage.IInspectRequestMsg['content']['detail_level'] + ): KernelMessage.IInspectReplyMsg['content'] { + const result = this._executor.inspect(code, cursorPos, detailLevel); + + return { + status: 'ok', + found: result.found, + data: result.data, + metadata: result.metadata + }; + } + + /** + * Check whether the provided code is complete. + */ + isComplete(code: string): KernelMessage.IIsCompleteReplyMsg['content'] { + const result = this._executor.isComplete(code); + + return { + status: result.status, + indent: result.indent || '' + }; + } + + /** + * Evaluate an async function within the configured global scope. + */ + private _evalFunc(asyncFunc: () => Promise): Promise { + return asyncFunc.call(this._globalScope); + } + + /** + * Patch console methods in runtime scope to emit Jupyter stream messages. + */ + private _setupConsoleOverrides(): void { + const scopeConsole = this._globalScope.console as Console | undefined; + if (!scopeConsole) { + return; + } + + this._originalConsole = { + log: scopeConsole.log, + info: scopeConsole.info, + error: scopeConsole.error, + warn: scopeConsole.warn + }; + + const toText = (args: any[]) => args.join(' ') + '\n'; + + scopeConsole.log = (...args: any[]) => { + this._onOutput({ + type: 'stream', + bundle: { name: 'stdout', text: toText(args) } + }); + }; + + scopeConsole.info = scopeConsole.log; + + scopeConsole.error = (...args: any[]) => { + this._onOutput({ + type: 'stream', + bundle: { name: 'stderr', text: toText(args) } + }); + }; + + scopeConsole.warn = scopeConsole.error; + + if ('onerror' in this._globalScope) { + this._originalOnError = this._globalScope.onerror; + this._globalScope.onerror = (message: any) => { + scopeConsole.error(message); + return false; + }; + } + } + + /** + * Restore original console methods if they were patched. + */ + private _restoreConsoleOverrides(): void { + if (!this._originalConsole) { + return; + } + + const scopeConsole = this._globalScope.console as Console | undefined; + if (scopeConsole) { + scopeConsole.log = this._originalConsole.log; + scopeConsole.info = this._originalConsole.info; + scopeConsole.error = this._originalConsole.error; + scopeConsole.warn = this._originalConsole.warn; + } + + if ('onerror' in this._globalScope) { + this._globalScope.onerror = this._originalOnError; + } + + this._originalConsole = null; + this._originalOnError = undefined; + } + + /** + * Install display() helper in runtime scope. + */ + private _setupDisplay(): void { + this._previousDisplay = this._globalScope.display; + + this._globalScope.display = (obj: any, metadata?: Record) => { + const data = this._executor.getMimeBundle(obj); + + this._onOutput({ + type: 'display_data', + bundle: { + data, + metadata: metadata ?? {}, + transient: {} + } + }); + }; + } + + /** + * Restore previous display binding. + */ + private _restoreDisplay(): void { + if (this._previousDisplay === undefined) { + delete this._globalScope.display; + return; + } + + this._globalScope.display = this._previousDisplay; + } + + /** + * Normalize thrown value to an Error instance. + */ + private _normalizeError(error: unknown): Error { + if (error instanceof Error) { + return error; + } + + return new Error(String(error)); + } + + private _globalScope: Record; + private _onOutput: RuntimeOutputHandler; + private _executor: JavaScriptExecutor; + private _previousDisplay: any; + private _originalOnError: any; + private _originalConsole: { + log: Console['log']; + info: Console['info']; + error: Console['error']; + warn: Console['warn']; + } | null = null; +} + +/** + * A namespace for JavaScriptRuntimeEvaluator statics. + */ +export namespace JavaScriptRuntimeEvaluator { + /** + * The instantiation options for a runtime evaluator. + */ + export interface IOptions { + globalScope: Record; + onOutput: RuntimeOutputHandler; + executor?: JavaScriptExecutor; + } +} diff --git a/packages/javascript-kernel/src/runtime_protocol.ts b/packages/javascript-kernel/src/runtime_protocol.ts new file mode 100644 index 0000000..b362d53 --- /dev/null +++ b/packages/javascript-kernel/src/runtime_protocol.ts @@ -0,0 +1,160 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import type { KernelMessage } from '@jupyterlab/services'; + +/** + * Supported runtime backends for the JavaScript kernel. + */ +export type RuntimeMode = 'iframe' | 'worker'; + +/** + * Output messages emitted by runtime backends. + */ +export type RuntimeOutputMessage = + | { + type: 'stream'; + bundle: KernelMessage.IStreamMsg['content']; + } + | { + type: 'input_request'; + content: KernelMessage.IInputRequestMsg['content']; + } + | { + type: 'display_data'; + bundle: KernelMessage.IDisplayDataMsg['content']; + } + | { + type: 'update_display_data'; + bundle: KernelMessage.IUpdateDisplayDataMsg['content']; + } + | { + type: 'clear_output'; + bundle: KernelMessage.IClearOutputMsg['content']; + } + | { + type: 'execute_result'; + bundle: KernelMessage.IExecuteResultMsg['content']; + } + | { + type: 'execute_error'; + bundle: KernelMessage.IReplyErrorContent; + }; + +/** + * Callback invoked when a runtime emits output. + */ +export type RuntimeOutputHandler = (message: RuntimeOutputMessage) => void; + +/** + * Execute request sent to a runtime backend. + */ +export interface IExecuteRuntimeRequest { + type: 'execute_request'; + content: KernelMessage.IExecuteRequestMsg['content']; + execution_count: number; +} + +/** + * Completion request sent to a runtime backend. + */ +export interface ICompleteRuntimeRequest { + type: 'complete_request'; + content: KernelMessage.ICompleteRequestMsg['content']; +} + +/** + * Inspection request sent to a runtime backend. + */ +export interface IInspectRuntimeRequest { + type: 'inspect_request'; + content: KernelMessage.IInspectRequestMsg['content']; +} + +/** + * is_complete request sent to a runtime backend. + */ +export interface IIsCompleteRuntimeRequest { + type: 'is_complete_request'; + content: KernelMessage.IIsCompleteRequestMsg['content']; +} + +/** + * Any request that can be handled by a runtime backend. + */ +export type RuntimeRequest = + | IExecuteRuntimeRequest + | ICompleteRuntimeRequest + | IInspectRuntimeRequest + | IIsCompleteRuntimeRequest; + +/** + * Response payloads for runtime requests. + */ +export type RuntimeResponse = + | KernelMessage.IExecuteReplyMsg['content'] + | KernelMessage.ICompleteReplyMsg['content'] + | KernelMessage.IInspectReplyMsg['content'] + | KernelMessage.IIsCompleteReplyMsg['content']; + +/** + * Request envelope sent from main thread to worker runtime. + */ +export interface IWorkerRuntimeRequestEnvelope { + kind: 'request'; + id: number; + request: RuntimeRequest; +} + +/** + * Successful response from a worker runtime. + */ +export interface IWorkerRuntimeSuccessEnvelope { + kind: 'response'; + id: number; + ok: true; + payload: RuntimeResponse; +} + +/** + * Error response from a worker runtime. + */ +export interface IWorkerRuntimeErrorEnvelope { + kind: 'response'; + id: number; + ok: false; + error: { + name: string; + message: string; + stack?: string; + }; +} + +/** + * Output envelope emitted by a worker runtime. + */ +export interface IWorkerRuntimeOutputEnvelope { + kind: 'output'; + message: RuntimeOutputMessage; +} + +/** + * Ready envelope emitted when the worker runtime is initialized. + */ +export interface IWorkerRuntimeReadyEnvelope { + kind: 'ready'; +} + +/** + * Messages posted to a worker runtime. + */ +export type WorkerRuntimeInboundMessage = IWorkerRuntimeRequestEnvelope; + +/** + * Messages posted from a worker runtime. + */ +export type WorkerRuntimeOutboundMessage = + | IWorkerRuntimeSuccessEnvelope + | IWorkerRuntimeErrorEnvelope + | IWorkerRuntimeOutputEnvelope + | IWorkerRuntimeReadyEnvelope; diff --git a/packages/javascript-kernel/src/worker-runtime.ts b/packages/javascript-kernel/src/worker-runtime.ts new file mode 100644 index 0000000..a996052 --- /dev/null +++ b/packages/javascript-kernel/src/worker-runtime.ts @@ -0,0 +1,175 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { JavaScriptRuntimeEvaluator } from './runtime_evaluator'; +import type { + RuntimeRequest, + RuntimeResponse, + WorkerRuntimeInboundMessage, + WorkerRuntimeOutboundMessage +} from './runtime_protocol'; + +const workerScope = self as unknown as Worker; +const runtimeGlobal = self as unknown as Record; + +const evaluator = new JavaScriptRuntimeEvaluator({ + globalScope: runtimeGlobal, + onOutput: message => { + postSafe({ + kind: 'output', + message + }); + } +}); + +workerScope.onmessage = async event => { + const data = event.data as WorkerRuntimeInboundMessage; + if (!data || data.kind !== 'request') { + return; + } + + const { id, request } = data; + + try { + const payload = await handleRequest(request); + postSafe({ + kind: 'response', + id, + ok: true, + payload + }); + } catch (error) { + const normalized = normalizeError(error); + postSafe({ + kind: 'response', + id, + ok: false, + error: { + name: normalized.name, + message: normalized.message, + stack: normalized.stack + } + }); + } +}; + +postSafe({ + kind: 'ready' +}); + +/** + * Dispatch runtime request to evaluator. + */ +async function handleRequest( + request: RuntimeRequest +): Promise { + switch (request.type) { + case 'execute_request': + return evaluator.execute(request.content.code, request.execution_count); + case 'complete_request': + return evaluator.complete( + request.content.code, + request.content.cursor_pos + ); + case 'inspect_request': + return evaluator.inspect( + request.content.code, + request.content.cursor_pos, + request.content.detail_level + ); + case 'is_complete_request': + return evaluator.isComplete(request.content.code); + default: + throw new Error(`Unknown runtime request type: ${(request as any).type}`); + } +} + +/** + * Post message after making sure payload is clone-safe. + */ +function postSafe(message: WorkerRuntimeOutboundMessage): void { + workerScope.postMessage(makeCloneSafe(message)); +} + +/** + * Make outbound payload clone-safe for postMessage. + */ +function makeCloneSafe(value: T): T { + if (typeof structuredClone === 'function') { + try { + structuredClone(value); + return value; + } catch { + // fall through to sanitization + } + } + + return sanitize(value, new WeakSet(), 0) as T; +} + +/** + * Convert unsupported values (functions, cyclic objects) to plain data. + */ +function sanitize(value: any, seen: WeakSet, depth: number): any { + if (value === null || value === undefined) { + return value; + } + + if (depth > 8) { + return '[Truncated]'; + } + + const valueType = typeof value; + if ( + valueType === 'string' || + valueType === 'number' || + valueType === 'boolean' + ) { + return value; + } + if (valueType === 'bigint') { + return value.toString(); + } + if (valueType === 'symbol' || valueType === 'function') { + return String(value); + } + + if (value instanceof Error) { + return { + name: value.name, + message: value.message, + stack: value.stack + }; + } + + if (Array.isArray(value)) { + return value.map(item => sanitize(item, seen, depth + 1)); + } + + if (valueType === 'object') { + if (seen.has(value)) { + return '[Circular]'; + } + seen.add(value); + + const output: Record = {}; + for (const [key, item] of Object.entries(value)) { + output[key] = sanitize(item, seen, depth + 1); + } + + seen.delete(value); + return output; + } + + return String(value); +} + +/** + * Normalize unknown thrown value into Error. + */ +function normalizeError(error: unknown): Error { + if (error instanceof Error) { + return error; + } + return new Error(String(error)); +} From 9e564a748657bb0a4fd329255b5d817a00bfe368 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Fri, 20 Feb 2026 21:41:26 +0100 Subject: [PATCH 12/20] fixes --- README.md | 2 +- .../javascript-kernel-extension/src/index.ts | 2 - packages/javascript-kernel/src/errors.ts | 55 +++++++++++++++++++ packages/javascript-kernel/src/kernel.ts | 41 ++++++++++---- .../javascript-kernel/src/runtime_backends.ts | 19 ++----- .../src/runtime_evaluator.ts | 14 +---- .../javascript-kernel/src/worker-runtime.ts | 11 +--- 7 files changed, 93 insertions(+), 51 deletions(-) create mode 100644 packages/javascript-kernel/src/errors.ts diff --git a/README.md b/README.md index a38c610..2118e69 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ pip uninstall jupyterlite-javascript-kernel The extension currently registers two JavaScript kernelspecs: - `JavaScript`: - Runs code in a sandboxed `iframe`. Use this when your code needs browser DOM APIs like `document`, `window`, or canvas access through the page context. + 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 (Worker)`: Runs code in a dedicated Web Worker. Use this for stronger isolation and to avoid blocking the main UI thread. diff --git a/packages/javascript-kernel-extension/src/index.ts b/packages/javascript-kernel-extension/src/index.ts index e1e71e8..309e68d 100644 --- a/packages/javascript-kernel-extension/src/index.ts +++ b/packages/javascript-kernel-extension/src/index.ts @@ -70,7 +70,6 @@ const kernelIFrame: JupyterFrontEndPlugin = { autoStart: true, requires: [IKernelSpecs], activate: (app: JupyterFrontEnd, kernelspecs: IKernelSpecs) => { - void app; registerKernel(kernelspecs, { name: 'javascript', displayName: 'JavaScript', @@ -87,7 +86,6 @@ const kernelWorker: JupyterFrontEndPlugin = { autoStart: true, requires: [IKernelSpecs], activate: (app: JupyterFrontEnd, kernelspecs: IKernelSpecs) => { - void app; registerKernel(kernelspecs, { name: 'javascript-worker', displayName: 'JavaScript (Worker)', 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/kernel.ts b/packages/javascript-kernel/src/kernel.ts index cea7241..3caeaff 100644 --- a/packages/javascript-kernel/src/kernel.ts +++ b/packages/javascript-kernel/src/kernel.ts @@ -6,6 +6,7 @@ import type { KernelMessage } from '@jupyterlab/services'; import { BaseKernel, type IKernel } from '@jupyterlite/services'; import type { JavaScriptExecutor } from './executor'; +import { normalizeError as normalizeUnknownError } from './errors'; import { IFrameRuntimeBackend, IRuntimeBackend, @@ -196,28 +197,28 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { * Send an `input_reply` message. */ inputReply(content: KernelMessage.IInputReplyMsg['content']): void { - throw new Error('Not implemented'); + this._logUnsupportedControlMessage('input_reply'); } /** * Send an `comm_open` message. */ async commOpen(msg: KernelMessage.ICommOpenMsg): Promise { - throw new Error('Not implemented'); + this._logUnsupportedControlMessage('comm_open', msg.content.target_name); } /** * Send an `comm_msg` message. */ async commMsg(msg: KernelMessage.ICommMsgMsg): Promise { - throw new Error('Not implemented'); + this._logUnsupportedControlMessage('comm_msg'); } /** * Send an `comm_close` message. */ async commClose(msg: KernelMessage.ICommCloseMsg): Promise { - throw new Error('Not implemented'); + this._logUnsupportedControlMessage('comm_close'); } /** @@ -248,7 +249,7 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { execute: async code => { const reply = await context.execute(code); if (reply.status === 'error') { - throw this.createRuntimeInitializationError(reply); + throw this._createRuntimeInitializationError(reply); } return reply; } @@ -308,17 +309,13 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { * Normalize unknown thrown values into Error instances. */ protected normalizeError(error: unknown): Error { - if (error instanceof Error) { - return error; - } - - return new Error(String(error)); + return normalizeUnknownError(error, 'RuntimeError'); } /** * Normalize an execute reply error into an Error instance. */ - private createRuntimeInitializationError( + private _createRuntimeInitializationError( reply: KernelMessage.IExecuteReplyMsg['content'] ): Error { const ename = @@ -343,6 +340,28 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { return error; } + /** + * Warn once per unsupported control message type to avoid noisy consoles. + */ + private _logUnsupportedControlMessage( + type: 'input_reply' | 'comm_open' | 'comm_msg' | 'comm_close', + detail?: string + ): void { + if (this._unsupportedControlMessages.has(type)) { + return; + } + + this._unsupportedControlMessages.add(type); + const suffix = detail ? ` (${detail})` : ''; + + console.warn( + `[javascript-kernel] Ignoring unsupported ${type} message${suffix}.` + ); + } + + private _unsupportedControlMessages = new Set< + 'input_reply' | 'comm_open' | 'comm_msg' | 'comm_close' + >(); private _backend: IRuntimeBackend; private _executorFactory?: JavaScriptKernel.IExecutorFactory; private _runtimeMode: RuntimeMode; diff --git a/packages/javascript-kernel/src/runtime_backends.ts b/packages/javascript-kernel/src/runtime_backends.ts index 0a02645..12af742 100644 --- a/packages/javascript-kernel/src/runtime_backends.ts +++ b/packages/javascript-kernel/src/runtime_backends.ts @@ -6,6 +6,7 @@ import type { KernelMessage } from '@jupyterlab/services'; import { PromiseDelegate } from '@lumino/coreutils'; import type { JavaScriptExecutor } from './executor'; +import { normalizeError } from './errors'; import { JavaScriptRuntimeEvaluator } from './runtime_evaluator'; import type { RuntimeOutputHandler, @@ -169,13 +170,11 @@ export class IFrameRuntimeBackend implements IRuntimeBackend { private async _init(): Promise { try { this._container = document.createElement('div'); - this._container.style.cssText = - 'position:absolute;width:0;height:0;overflow:hidden;'; + this._container.style.display = 'none'; document.body.appendChild(this._container); this._iframe = document.createElement('iframe'); - this._iframe.sandbox.add('allow-scripts', 'allow-same-origin'); - this._iframe.style.cssText = 'border:none;width:100%;height:100%;'; + this._iframe.style.border = 'none'; this._iframe.srcdoc = ` @@ -500,7 +499,7 @@ export class WorkerRuntimeBackend implements IRuntimeBackend { }); this._ready.resolve(); } catch (error) { - this._handleWorkerFatal(this._normalizeError(error)); + this._handleWorkerFatal(normalizeError(error)); } } @@ -524,16 +523,6 @@ export class WorkerRuntimeBackend implements IRuntimeBackend { this._pending.clear(); } - /** - * Normalize unknown values to Error instances. - */ - private _normalizeError(error: unknown): Error { - if (error instanceof Error) { - return error; - } - return new Error(String(error)); - } - private _options: WorkerRuntimeBackend.IOptions; private _worker: Worker | null = null; private _readyHandled = false; diff --git a/packages/javascript-kernel/src/runtime_evaluator.ts b/packages/javascript-kernel/src/runtime_evaluator.ts index c1d453b..befd647 100644 --- a/packages/javascript-kernel/src/runtime_evaluator.ts +++ b/packages/javascript-kernel/src/runtime_evaluator.ts @@ -4,6 +4,7 @@ import type { KernelMessage } from '@jupyterlab/services'; import { JavaScriptExecutor } from './executor'; +import { normalizeError } from './errors'; import type { RuntimeOutputHandler } from './runtime_protocol'; /** @@ -82,7 +83,7 @@ export class JavaScriptRuntimeEvaluator { user_expressions: {} }; } catch (error) { - const normalized = this._normalizeError(error); + const normalized = normalizeError(error); const cleanedStack = this._executor.cleanStackTrace(normalized); const content: KernelMessage.IReplyErrorContent = { @@ -260,17 +261,6 @@ export class JavaScriptRuntimeEvaluator { this._globalScope.display = this._previousDisplay; } - /** - * Normalize thrown value to an Error instance. - */ - private _normalizeError(error: unknown): Error { - if (error instanceof Error) { - return error; - } - - return new Error(String(error)); - } - private _globalScope: Record; private _onOutput: RuntimeOutputHandler; private _executor: JavaScriptExecutor; diff --git a/packages/javascript-kernel/src/worker-runtime.ts b/packages/javascript-kernel/src/worker-runtime.ts index a996052..a94ee72 100644 --- a/packages/javascript-kernel/src/worker-runtime.ts +++ b/packages/javascript-kernel/src/worker-runtime.ts @@ -1,6 +1,7 @@ // Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. +import { normalizeError } from './errors'; import { JavaScriptRuntimeEvaluator } from './runtime_evaluator'; import type { RuntimeRequest, @@ -163,13 +164,3 @@ function sanitize(value: any, seen: WeakSet, depth: number): any { return String(value); } - -/** - * Normalize unknown thrown value into Error. - */ -function normalizeError(error: unknown): Error { - if (error instanceof Error) { - return error; - } - return new Error(String(error)); -} From 387df22983c109beb82846bb62bb757cdd1bca22 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Sat, 21 Feb 2026 10:06:34 +0100 Subject: [PATCH 13/20] comlink and web worker example --- examples/web-worker-compute.ipynb | 241 +++++++++++ packages/javascript-kernel/package.json | 1 + packages/javascript-kernel/src/kernel.ts | 12 +- .../javascript-kernel/src/runtime_backends.ts | 397 ++++++++---------- .../javascript-kernel/src/runtime_protocol.ts | 138 ++---- .../javascript-kernel/src/runtime_remote.ts | 147 +++++++ .../javascript-kernel/src/worker-runtime.ts | 164 +------- yarn.lock | 1 + 8 files changed, 612 insertions(+), 489 deletions(-) create mode 100644 examples/web-worker-compute.ipynb create mode 100644 packages/javascript-kernel/src/runtime_remote.ts 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", + "`

Benchmark Results

${rows}
`" + ] + } + ], + "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/package.json b/packages/javascript-kernel/package.json index 4146c37..f704d89 100644 --- a/packages/javascript-kernel/package.json +++ b/packages/javascript-kernel/package.json @@ -47,6 +47,7 @@ "@jupyterlite/services": "^0.7.0", "@lumino/coreutils": "^2.0.0", "astring": "^1.9.0", + "comlink": "^4.3.1", "meriyah": "^4.3.9" }, "devDependencies": { diff --git a/packages/javascript-kernel/src/kernel.ts b/packages/javascript-kernel/src/kernel.ts index 3caeaff..543e0cf 100644 --- a/packages/javascript-kernel/src/kernel.ts +++ b/packages/javascript-kernel/src/kernel.ts @@ -265,8 +265,14 @@ export class JavaScriptKernel extends BaseKernel implements IKernel { await this.onRuntimeReady({ runtime: 'iframe', globalScope: context.globalScope, - executor: context.evaluator.executor, - execute: async code => Promise.resolve(context.evaluate(code)) + executor: context.executor, + execute: async code => { + const reply = await context.execute(code); + if (reply.status === 'error') { + throw this._createRuntimeInitializationError(reply); + } + return reply; + } }); } }); @@ -403,7 +409,7 @@ export namespace JavaScriptKernel { | IWorkerRuntimeReadyContext; /** - * Factory used to customize the iframe runtime executor. + * Factory used to customize iframe runtime evaluation behavior. */ export type IExecutorFactory = ( globalScope: Record diff --git a/packages/javascript-kernel/src/runtime_backends.ts b/packages/javascript-kernel/src/runtime_backends.ts index 12af742..2f48310 100644 --- a/packages/javascript-kernel/src/runtime_backends.ts +++ b/packages/javascript-kernel/src/runtime_backends.ts @@ -5,15 +5,16 @@ import type { KernelMessage } from '@jupyterlab/services'; import { PromiseDelegate } from '@lumino/coreutils'; -import type { JavaScriptExecutor } from './executor'; +import * as Comlink from 'comlink'; + +import { JavaScriptExecutor } from './executor'; import { normalizeError } from './errors'; -import { JavaScriptRuntimeEvaluator } from './runtime_evaluator'; +import { createRemoteRuntimeApi } from './runtime_remote'; import type { + IRemoteRuntimeApi, + RuntimeOutputCallback, RuntimeOutputHandler, - RuntimeRequest, - RuntimeResponse, - WorkerRuntimeInboundMessage, - WorkerRuntimeOutboundMessage + RuntimeOutputMessage } from './runtime_protocol'; /** @@ -48,7 +49,7 @@ export interface IRuntimeBackend { } /** - * Runtime backend that executes code in a hidden iframe. + * Runtime backend that executes code in a hidden iframe through Comlink. */ export class IFrameRuntimeBackend implements IRuntimeBackend { /** @@ -73,29 +74,17 @@ export class IFrameRuntimeBackend implements IRuntimeBackend { return this._iframe; } - /** - * The runtime global scope. - */ - get globalScope(): Record | null { - return this._iframe?.contentWindow - ? (this._iframe.contentWindow as Record) - : null; - } - - /** - * The runtime evaluator. - */ - get evaluator(): JavaScriptRuntimeEvaluator | null { - return this._evaluator; - } - /** * Dispose iframe resources. */ dispose(): void { this._ready.reject(new Error('IFrame runtime disposed')); - this._evaluator?.dispose(); - this._evaluator = null; + + if (this._remote) { + void this._remote.dispose().catch(() => undefined); + this._remote[Comlink.releaseProxy](); + this._remote = null; + } this._iframe?.remove(); this._iframe = null; @@ -104,6 +93,10 @@ export class IFrameRuntimeBackend implements IRuntimeBackend { this._container.remove(); this._container = null; } + + this._outputProxy = null; + this._globalScope = null; + this._executor = null; } /** @@ -114,7 +107,7 @@ export class IFrameRuntimeBackend implements IRuntimeBackend { executionCount: number ): Promise { await this.ready; - return this._getEvaluator().execute(code, executionCount); + return this._getRemote().execute(code, executionCount); } /** @@ -125,7 +118,7 @@ export class IFrameRuntimeBackend implements IRuntimeBackend { cursorPos: number ): Promise { await this.ready; - return this._getEvaluator().complete(code, cursorPos); + return this._getRemote().complete(code, cursorPos); } /** @@ -137,7 +130,7 @@ export class IFrameRuntimeBackend implements IRuntimeBackend { detailLevel: KernelMessage.IInspectRequestMsg['content']['detail_level'] ): Promise { await this.ready; - return this._getEvaluator().inspect(code, cursorPos, detailLevel); + return this._getRemote().inspect(code, cursorPos, detailLevel); } /** @@ -147,25 +140,11 @@ export class IFrameRuntimeBackend implements IRuntimeBackend { code: string ): Promise { await this.ready; - return this._getEvaluator().isComplete(code); + return this._getRemote().isComplete(code); } /** - * Evaluate raw code in the iframe global scope. - */ - evaluate(code: string): any { - const globalScope = this._getGlobalScope(); - const scopeFunction = globalScope.Function; - const functionConstructor = - typeof scopeFunction === 'function' - ? (scopeFunction as FunctionConstructor) - : Function; - const evaluateCode = functionConstructor(code); - return evaluateCode.call(globalScope); - } - - /** - * Initialize iframe and evaluator. + * Initialize iframe and remote runtime API. */ private async _init(): Promise { try { @@ -186,73 +165,113 @@ export class IFrameRuntimeBackend implements IRuntimeBackend { this._container.appendChild(this._iframe); - await new Promise(resolve => { + await new Promise((resolve, reject) => { if (!this._iframe) { - resolve(); + reject(new Error('IFrame runtime is not initialized')); return; } + this._iframe.onload = () => resolve(); + this._iframe.onerror = () => { + reject(new Error('IFrame runtime failed to load')); + }; }); if (!this._iframe?.contentWindow) { throw new Error('IFrame window not available'); } - const globalScope = this._iframe.contentWindow as Record; - const executor = this._options.executorFactory?.(globalScope); - this._evaluator = new JavaScriptRuntimeEvaluator({ - globalScope, - onOutput: this._options.onOutput, - executor + this._globalScope = this._iframe.contentWindow as Record; + this._executor = + this._options.executorFactory?.(this._globalScope) ?? + new JavaScriptExecutor(this._globalScope); + + // Bind expose/listen on the iframe window context so RPC still flows + // through postMessage without requiring an inline iframe bootstrap script. + const exposedEndpoint = Comlink.windowEndpoint( + window, + this._iframe.contentWindow, + '*' + ); + Comlink.expose( + createRemoteRuntimeApi(this._globalScope, this._executor), + exposedEndpoint + ); + + const endpoint = Comlink.windowEndpoint( + this._iframe.contentWindow, + window, + '*' + ); + const remote = Comlink.wrap(endpoint); + const outputProxy = Comlink.proxy((message: RuntimeOutputMessage) => { + this._options.onOutput(message); }); + this._remote = remote; + this._outputProxy = outputProxy; + const activeOutputProxy = this._outputProxy; + if (!activeOutputProxy) { + throw new Error('IFrame runtime output handler is not initialized'); + } + + await withTimeout( + remote.initialize(activeOutputProxy), + IFrameRuntimeBackend.STARTUP_TIMEOUT_MS, + 'IFrame runtime failed to initialize' + ); + await this._options.onReady?.({ iframe: this._iframe, container: this._container, - globalScope, - evaluator: this._evaluator, - evaluate: code => this.evaluate(code) + globalScope: this._globalScope, + executor: this._executor, + execute: (code, executionCount = 0) => + remote.execute(code, executionCount) }); + this._ready.resolve(); } catch (error) { - this._evaluator?.dispose(); - this._evaluator = null; + if (this._remote) { + void this._remote.dispose().catch(() => undefined); + this._remote[Comlink.releaseProxy](); + this._remote = null; + } + this._iframe?.remove(); this._iframe = null; if (this._container) { this._container.remove(); this._container = null; } - this._ready.reject(error); - } - } - /** - * Return evaluator or throw when not initialized. - */ - private _getEvaluator(): JavaScriptRuntimeEvaluator { - if (!this._evaluator) { - throw new Error('IFrame runtime is not initialized'); + this._outputProxy = null; + this._globalScope = null; + this._executor = null; + this._ready.reject(error); } - return this._evaluator; } /** - * Return global scope or throw when not initialized. + * Return remote runtime API or throw when not initialized. */ - private _getGlobalScope(): Record { - const globalScope = this.globalScope; - if (!globalScope) { + private _getRemote(): Comlink.Remote { + if (!this._remote) { throw new Error('IFrame runtime is not initialized'); } - return globalScope; + return this._remote; } private _options: IFrameRuntimeBackend.IOptions; private _ready = new PromiseDelegate(); - private _evaluator: JavaScriptRuntimeEvaluator | null = null; + private _remote: Comlink.Remote | null = null; private _iframe: HTMLIFrameElement | null = null; private _container: HTMLDivElement | null = null; + private _outputProxy: RuntimeOutputCallback | null = null; + private _globalScope: Record | null = null; + private _executor: JavaScriptExecutor | null = null; + + static readonly STARTUP_TIMEOUT_MS = 10000; } /** @@ -266,8 +285,11 @@ export namespace IFrameRuntimeBackend { iframe: HTMLIFrameElement; container: HTMLDivElement; globalScope: Record; - evaluator: JavaScriptRuntimeEvaluator; - evaluate: (code: string) => any; + executor: JavaScriptExecutor; + execute: ( + code: string, + executionCount?: number + ) => Promise; } /** @@ -294,26 +316,32 @@ export class WorkerRuntimeBackend implements IRuntimeBackend { return; } - this._worker = new Worker(new URL('./worker-runtime.js', import.meta.url), { + const worker = new Worker(new URL('./worker-runtime.js', import.meta.url), { type: 'module' }); - this._worker.onmessage = event => { - this._onWorkerMessage(event.data as WorkerRuntimeOutboundMessage); - }; - this._worker.onerror = event => { + + worker.onerror = event => { const details = [event.message || 'Worker runtime failed to initialize']; if (event.filename) { details.push(`at ${event.filename}:${event.lineno}:${event.colno}`); } this._handleWorkerFatal(new Error(details.join(' '))); }; - this._worker.onmessageerror = () => { + worker.onmessageerror = () => { this._handleWorkerFatal( new Error( 'Worker runtime sent a message that could not be deserialized' ) ); }; + + this._worker = worker; + this._remote = Comlink.wrap(worker); + this._outputProxy = Comlink.proxy((message: RuntimeOutputMessage) => { + this._options.onOutput(message); + }); + + void this._init(); } /** @@ -328,10 +356,16 @@ export class WorkerRuntimeBackend implements IRuntimeBackend { */ dispose(): void { this._ready.reject(new Error('Worker runtime disposed')); + + if (this._remote) { + void this._remote.dispose().catch(() => undefined); + this._remote[Comlink.releaseProxy](); + this._remote = null; + } + this._worker?.terminate(); this._worker = null; - - this._rejectPending(new Error('Worker runtime disposed')); + this._outputProxy = null; } /** @@ -341,13 +375,8 @@ export class WorkerRuntimeBackend implements IRuntimeBackend { code: string, executionCount: number ): Promise { - return this._request({ - type: 'execute_request', - content: { - code - }, - execution_count: executionCount - }); + await this.ready; + return this._getRemote().execute(code, executionCount); } /** @@ -357,13 +386,8 @@ export class WorkerRuntimeBackend implements IRuntimeBackend { code: string, cursorPos: number ): Promise { - return this._request({ - type: 'complete_request', - content: { - code, - cursor_pos: cursorPos - } - }); + await this.ready; + return this._getRemote().complete(code, cursorPos); } /** @@ -374,14 +398,8 @@ export class WorkerRuntimeBackend implements IRuntimeBackend { cursorPos: number, detailLevel: KernelMessage.IInspectRequestMsg['content']['detail_level'] ): Promise { - return this._request({ - type: 'inspect_request', - content: { - code, - cursor_pos: cursorPos, - detail_level: detailLevel - } - }); + await this.ready; + return this._getRemote().inspect(code, cursorPos, detailLevel); } /** @@ -390,113 +408,34 @@ export class WorkerRuntimeBackend implements IRuntimeBackend { async isComplete( code: string ): Promise { - return this._request({ - type: 'is_complete_request', - content: { - code - } - }); - } - - /** - * Send a request to the worker and await response. - */ - private async _request(request: RuntimeRequest): Promise { - return this._requestWithMode(request, true); - } - - /** - * Send a request, optionally waiting for runtime readiness. - */ - private async _requestWithMode( - request: RuntimeRequest, - waitForReady: boolean - ): Promise { - if (waitForReady) { - await this.ready; - } - - if (!this._worker) { - throw new Error('Worker runtime is not initialized'); - } - - const id = this._nextRequestId++; - const envelope: WorkerRuntimeInboundMessage = { - kind: 'request', - id, - request - }; - - return new Promise((resolve, reject) => { - this._pending.set(id, { - resolve: value => resolve(value as T), - reject - }); - - try { - this._worker?.postMessage(envelope); - } catch (error) { - this._pending.delete(id); - reject(error as Error); - } - }); + await this.ready; + return this._getRemote().isComplete(code); } /** - * Handle worker output and request responses. + * Initialize remote worker API and execute optional initialization hook. */ - private _onWorkerMessage(message: WorkerRuntimeOutboundMessage): void { - switch (message.kind) { - case 'ready': - void this._handleWorkerReady(); - break; - case 'output': - this._options.onOutput(message.message); - break; - case 'response': { - const pending = this._pending.get(message.id); - if (!pending) { - return; - } - this._pending.delete(message.id); - if (message.ok) { - pending.resolve(message.payload as RuntimeResponse); - } else { - const error = new Error(message.error.message); - error.name = message.error.name; - error.stack = message.error.stack; - pending.reject(error); - } - break; - } - default: - break; - } - } + private async _init(): Promise { + const remote = this._remote; + const outputProxy = this._outputProxy; - /** - * Resolve readiness after optional initialization hook. - */ - private async _handleWorkerReady(): Promise { - if (this._readyHandled) { + if (!remote || !outputProxy) { + this._ready.reject(new Error('Worker runtime is not initialized')); return; } - this._readyHandled = true; try { + await withTimeout( + remote.initialize(outputProxy), + WorkerRuntimeBackend.STARTUP_TIMEOUT_MS, + 'Worker runtime failed to initialize' + ); + await this._options.onReady?.({ execute: (code, executionCount = 0) => - this._requestWithMode( - { - type: 'execute_request', - content: { - code - }, - execution_count: executionCount - }, - false - ) + remote.execute(code, executionCount) }); + this._ready.resolve(); } catch (error) { this._handleWorkerFatal(normalizeError(error)); @@ -504,37 +443,37 @@ export class WorkerRuntimeBackend implements IRuntimeBackend { } /** - * Reject all pending requests and initialization with a fatal worker error. + * Reject initialization with a fatal worker error. */ private _handleWorkerFatal(error: Error): void { + if (this._remote) { + this._remote[Comlink.releaseProxy](); + this._remote = null; + } + this._worker?.terminate(); this._worker = null; + this._outputProxy = null; this._ready.reject(error); - this._rejectPending(error); } /** - * Reject pending in-flight worker requests. + * Return remote runtime API or throw when not initialized. */ - private _rejectPending(error: Error): void { - for (const pending of this._pending.values()) { - pending.reject(error); + private _getRemote(): Comlink.Remote { + if (!this._remote) { + throw new Error('Worker runtime is not initialized'); } - this._pending.clear(); + return this._remote; } private _options: WorkerRuntimeBackend.IOptions; private _worker: Worker | null = null; - private _readyHandled = false; - private _nextRequestId = 1; + private _remote: Comlink.Remote | null = null; + private _outputProxy: RuntimeOutputCallback | null = null; private _ready = new PromiseDelegate(); - private _pending = new Map< - number, - { - resolve: (value: RuntimeResponse) => void; - reject: (reason: Error) => void; - } - >(); + + static readonly STARTUP_TIMEOUT_MS = 10000; } /** @@ -558,3 +497,29 @@ export namespace WorkerRuntimeBackend { onReady?: (context: IReadyContext) => void | Promise; } } + +/** + * Add a timeout to runtime startup operations. + */ +async function withTimeout( + promise: Promise, + timeoutMs: number, + errorMessage: string +): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error(errorMessage)); + }, timeoutMs); + + void promise.then( + value => { + clearTimeout(timeout); + resolve(value); + }, + error => { + clearTimeout(timeout); + reject(error as Error); + } + ); + }); +} diff --git a/packages/javascript-kernel/src/runtime_protocol.ts b/packages/javascript-kernel/src/runtime_protocol.ts index b362d53..6439cb2 100644 --- a/packages/javascript-kernel/src/runtime_protocol.ts +++ b/packages/javascript-kernel/src/runtime_protocol.ts @@ -47,114 +47,32 @@ export type RuntimeOutputMessage = export type RuntimeOutputHandler = (message: RuntimeOutputMessage) => void; /** - * Execute request sent to a runtime backend. - */ -export interface IExecuteRuntimeRequest { - type: 'execute_request'; - content: KernelMessage.IExecuteRequestMsg['content']; - execution_count: number; -} - -/** - * Completion request sent to a runtime backend. - */ -export interface ICompleteRuntimeRequest { - type: 'complete_request'; - content: KernelMessage.ICompleteRequestMsg['content']; -} - -/** - * Inspection request sent to a runtime backend. - */ -export interface IInspectRuntimeRequest { - type: 'inspect_request'; - content: KernelMessage.IInspectRequestMsg['content']; -} - -/** - * is_complete request sent to a runtime backend. - */ -export interface IIsCompleteRuntimeRequest { - type: 'is_complete_request'; - content: KernelMessage.IIsCompleteRequestMsg['content']; -} - -/** - * Any request that can be handled by a runtime backend. - */ -export type RuntimeRequest = - | IExecuteRuntimeRequest - | ICompleteRuntimeRequest - | IInspectRuntimeRequest - | IIsCompleteRuntimeRequest; - -/** - * Response payloads for runtime requests. - */ -export type RuntimeResponse = - | KernelMessage.IExecuteReplyMsg['content'] - | KernelMessage.ICompleteReplyMsg['content'] - | KernelMessage.IInspectReplyMsg['content'] - | KernelMessage.IIsCompleteReplyMsg['content']; - -/** - * Request envelope sent from main thread to worker runtime. - */ -export interface IWorkerRuntimeRequestEnvelope { - kind: 'request'; - id: number; - request: RuntimeRequest; + * Output callback passed across Comlink endpoints. + */ +export type RuntimeOutputCallback = ( + message: RuntimeOutputMessage +) => void | Promise; + +/** + * Runtime API exposed from iframe and worker contexts over Comlink. + */ +export interface IRemoteRuntimeApi { + initialize(onOutput: RuntimeOutputCallback): Promise; + execute( + code: string, + executionCount: number + ): Promise; + complete( + code: string, + cursorPos: number + ): Promise; + inspect( + code: string, + cursorPos: number, + detailLevel: KernelMessage.IInspectRequestMsg['content']['detail_level'] + ): Promise; + isComplete( + code: string + ): Promise; + dispose(): Promise; } - -/** - * Successful response from a worker runtime. - */ -export interface IWorkerRuntimeSuccessEnvelope { - kind: 'response'; - id: number; - ok: true; - payload: RuntimeResponse; -} - -/** - * Error response from a worker runtime. - */ -export interface IWorkerRuntimeErrorEnvelope { - kind: 'response'; - id: number; - ok: false; - error: { - name: string; - message: string; - stack?: string; - }; -} - -/** - * Output envelope emitted by a worker runtime. - */ -export interface IWorkerRuntimeOutputEnvelope { - kind: 'output'; - message: RuntimeOutputMessage; -} - -/** - * Ready envelope emitted when the worker runtime is initialized. - */ -export interface IWorkerRuntimeReadyEnvelope { - kind: 'ready'; -} - -/** - * Messages posted to a worker runtime. - */ -export type WorkerRuntimeInboundMessage = IWorkerRuntimeRequestEnvelope; - -/** - * Messages posted from a worker runtime. - */ -export type WorkerRuntimeOutboundMessage = - | IWorkerRuntimeSuccessEnvelope - | IWorkerRuntimeErrorEnvelope - | IWorkerRuntimeOutputEnvelope - | IWorkerRuntimeReadyEnvelope; diff --git a/packages/javascript-kernel/src/runtime_remote.ts b/packages/javascript-kernel/src/runtime_remote.ts new file mode 100644 index 0000000..05a0e96 --- /dev/null +++ b/packages/javascript-kernel/src/runtime_remote.ts @@ -0,0 +1,147 @@ +// Copyright (c) Jupyter Development Team. +// Distributed under the terms of the Modified BSD License. + +import { JavaScriptRuntimeEvaluator } from './runtime_evaluator'; +import type { JavaScriptExecutor } from './executor'; +import type { + IRemoteRuntimeApi, + RuntimeOutputCallback, + RuntimeOutputMessage +} from './runtime_protocol'; + +/** + * Create a Comlink runtime API bound to the provided global scope. + */ +export function createRemoteRuntimeApi( + globalScope: Record, + executor?: JavaScriptExecutor +): IRemoteRuntimeApi { + let evaluator: JavaScriptRuntimeEvaluator | null = null; + + const ensureEvaluator = (): JavaScriptRuntimeEvaluator => { + if (!evaluator) { + throw new Error('Runtime is not initialized'); + } + return evaluator; + }; + + const emitOutput = ( + callback: RuntimeOutputCallback, + message: RuntimeOutputMessage + ): void => { + void Promise.resolve(callback(makeCloneSafe(message))).catch(() => { + // Ignore output callback failures so execution replies can still resolve. + }); + }; + + return { + async initialize(onOutput: RuntimeOutputCallback): Promise { + evaluator?.dispose(); + evaluator = new JavaScriptRuntimeEvaluator({ + globalScope, + executor, + onOutput: message => { + emitOutput(onOutput, message); + } + }); + }, + + async execute(code: string, executionCount: number) { + return ensureEvaluator().execute(code, executionCount); + }, + + async complete(code: string, cursorPos: number) { + return ensureEvaluator().complete(code, cursorPos); + }, + + async inspect( + code: string, + cursorPos: number, + detailLevel: Parameters[2] + ) { + return ensureEvaluator().inspect(code, cursorPos, detailLevel); + }, + + async isComplete(code: string) { + return ensureEvaluator().isComplete(code); + }, + + async dispose(): Promise { + evaluator?.dispose(); + evaluator = null; + } + }; +} + +/** + * Make outbound payload clone-safe for Comlink transport. + */ +function makeCloneSafe(value: T): T { + if (typeof structuredClone === 'function') { + try { + structuredClone(value); + return value; + } catch { + // fall through to sanitization + } + } + + return sanitize(value, new WeakSet(), 0) as T; +} + +/** + * Convert unsupported values (functions, cyclic objects) to plain data. + */ +function sanitize(value: any, seen: WeakSet, depth: number): any { + if (value === null || value === undefined) { + return value; + } + + if (depth > 8) { + return '[Truncated]'; + } + + const valueType = typeof value; + if ( + valueType === 'string' || + valueType === 'number' || + valueType === 'boolean' + ) { + return value; + } + if (valueType === 'bigint') { + return value.toString(); + } + if (valueType === 'symbol' || valueType === 'function') { + return String(value); + } + + if (value instanceof Error) { + return { + name: value.name, + message: value.message, + stack: value.stack + }; + } + + if (Array.isArray(value)) { + return value.map(item => sanitize(item, seen, depth + 1)); + } + + if (valueType === 'object') { + if (seen.has(value)) { + return '[Circular]'; + } + seen.add(value); + + const output: Record = {}; + for (const [key, item] of Object.entries(value)) { + output[key] = sanitize(item, seen, depth + 1); + } + + seen.delete(value); + return output; + } + + return String(value); +} diff --git a/packages/javascript-kernel/src/worker-runtime.ts b/packages/javascript-kernel/src/worker-runtime.ts index a94ee72..4dc2315 100644 --- a/packages/javascript-kernel/src/worker-runtime.ts +++ b/packages/javascript-kernel/src/worker-runtime.ts @@ -1,166 +1,10 @@ // Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. -import { normalizeError } from './errors'; -import { JavaScriptRuntimeEvaluator } from './runtime_evaluator'; -import type { - RuntimeRequest, - RuntimeResponse, - WorkerRuntimeInboundMessage, - WorkerRuntimeOutboundMessage -} from './runtime_protocol'; +import * as Comlink from 'comlink'; -const workerScope = self as unknown as Worker; -const runtimeGlobal = self as unknown as Record; - -const evaluator = new JavaScriptRuntimeEvaluator({ - globalScope: runtimeGlobal, - onOutput: message => { - postSafe({ - kind: 'output', - message - }); - } -}); - -workerScope.onmessage = async event => { - const data = event.data as WorkerRuntimeInboundMessage; - if (!data || data.kind !== 'request') { - return; - } - - const { id, request } = data; - - try { - const payload = await handleRequest(request); - postSafe({ - kind: 'response', - id, - ok: true, - payload - }); - } catch (error) { - const normalized = normalizeError(error); - postSafe({ - kind: 'response', - id, - ok: false, - error: { - name: normalized.name, - message: normalized.message, - stack: normalized.stack - } - }); - } -}; - -postSafe({ - kind: 'ready' -}); - -/** - * Dispatch runtime request to evaluator. - */ -async function handleRequest( - request: RuntimeRequest -): Promise { - switch (request.type) { - case 'execute_request': - return evaluator.execute(request.content.code, request.execution_count); - case 'complete_request': - return evaluator.complete( - request.content.code, - request.content.cursor_pos - ); - case 'inspect_request': - return evaluator.inspect( - request.content.code, - request.content.cursor_pos, - request.content.detail_level - ); - case 'is_complete_request': - return evaluator.isComplete(request.content.code); - default: - throw new Error(`Unknown runtime request type: ${(request as any).type}`); - } -} - -/** - * Post message after making sure payload is clone-safe. - */ -function postSafe(message: WorkerRuntimeOutboundMessage): void { - workerScope.postMessage(makeCloneSafe(message)); -} - -/** - * Make outbound payload clone-safe for postMessage. - */ -function makeCloneSafe(value: T): T { - if (typeof structuredClone === 'function') { - try { - structuredClone(value); - return value; - } catch { - // fall through to sanitization - } - } +import { createRemoteRuntimeApi } from './runtime_remote'; - return sanitize(value, new WeakSet(), 0) as T; -} - -/** - * Convert unsupported values (functions, cyclic objects) to plain data. - */ -function sanitize(value: any, seen: WeakSet, depth: number): any { - if (value === null || value === undefined) { - return value; - } - - if (depth > 8) { - return '[Truncated]'; - } - - const valueType = typeof value; - if ( - valueType === 'string' || - valueType === 'number' || - valueType === 'boolean' - ) { - return value; - } - if (valueType === 'bigint') { - return value.toString(); - } - if (valueType === 'symbol' || valueType === 'function') { - return String(value); - } - - if (value instanceof Error) { - return { - name: value.name, - message: value.message, - stack: value.stack - }; - } - - if (Array.isArray(value)) { - return value.map(item => sanitize(item, seen, depth + 1)); - } - - if (valueType === 'object') { - if (seen.has(value)) { - return '[Circular]'; - } - seen.add(value); - - const output: Record = {}; - for (const [key, item] of Object.entries(value)) { - output[key] = sanitize(item, seen, depth + 1); - } - - seen.delete(value); - return output; - } +const runtimeGlobal = self as unknown as Record; - return String(value); -} +Comlink.expose(createRemoteRuntimeApi(runtimeGlobal)); diff --git a/yarn.lock b/yarn.lock index 2a4847f..ac2af03 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3391,6 +3391,7 @@ __metadata: "@lumino/coreutils": ^2.0.0 "@types/jest": ^26.0.10 astring: ^1.9.0 + comlink: ^4.3.1 jest: ^26.4.2 meriyah: ^4.3.9 rimraf: ~5.0.1 From c862125740a769a3b4aa4d03b0897c1533bbf258 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Sat, 21 Feb 2026 10:36:05 +0100 Subject: [PATCH 14/20] type fixes --- packages/javascript-kernel/src/display.ts | 13 +++++---- packages/javascript-kernel/src/executor.ts | 29 ++++++++++--------- .../src/runtime_evaluator.ts | 16 ++-------- 3 files changed, 25 insertions(+), 33 deletions(-) diff --git a/packages/javascript-kernel/src/display.ts b/packages/javascript-kernel/src/display.ts index 4345c8f..a2212fd 100644 --- a/packages/javascript-kernel/src/display.ts +++ b/packages/javascript-kernel/src/display.ts @@ -1,6 +1,8 @@ // Copyright (c) Jupyter Development Team. // Distributed under the terms of the Modified BSD License. +import type { KernelMessage } from '@jupyterlab/services'; + /** * MIME bundle for rich display. */ @@ -11,13 +13,12 @@ export interface IMimeBundle { /** * Display request from display(). */ -export interface IDisplayData { +export type IDisplayData = Omit< + KernelMessage.IDisplayDataMsg['content'], + 'data' +> & { data: IMimeBundle; - metadata: Record; - transient?: { - display_id?: string; - }; -} +}; /** * Callbacks for display operations. diff --git a/packages/javascript-kernel/src/executor.ts b/packages/javascript-kernel/src/executor.ts index a9f4e0b..eb46f08 100644 --- a/packages/javascript-kernel/src/executor.ts +++ b/packages/javascript-kernel/src/executor.ts @@ -1,6 +1,8 @@ // 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'; @@ -61,19 +63,14 @@ export interface ICompletionResult { /** * Result of code completeness check. */ -export interface IIsCompleteResult { - status: 'complete' | 'incomplete' | 'invalid' | 'unknown'; - indent?: string; -} +export type IIsCompleteResult = + | KernelMessage.IIsCompleteReplyIncomplete + | KernelMessage.IIsCompleteReplyOther; /** * Result of code inspection. */ -export interface IInspectResult { - found: boolean; - data: IMimeBundle; - metadata: Record; -} +export type IInspectResult = KernelMessage.IInspectReply; /** * Registry for tracking code declarations across cells. @@ -904,13 +901,14 @@ export class JavaScriptExecutor { inspect( code: string, cursorPos: number, - detailLevel: number = 0 + 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: {} @@ -933,6 +931,7 @@ export class JavaScriptExecutor { ); return { + status: 'ok', found: true, data: inspectionData, metadata: {} @@ -965,6 +964,7 @@ export class JavaScriptExecutor { const doc = this.getBuiltinDocumentation(expression); if (doc) { return { + status: 'ok', found: true, data: { 'text/plain': `${expression}: ${doc}`, @@ -978,6 +978,7 @@ export class JavaScriptExecutor { 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(', ')}?`, @@ -988,6 +989,7 @@ export class JavaScriptExecutor { } return { + status: 'ok', found: false, data: {}, metadata: {} @@ -1870,14 +1872,14 @@ export class JavaScriptExecutor { for (const part of parts) { if (value === null || value === undefined) { - return { found: false, data: {}, metadata: {} }; + return { status: 'ok', found: false, data: {}, metadata: {} }; } const hasProp = part in value || Object.prototype.hasOwnProperty.call(value, part); if (hasProp) { value = value[part]; } else { - return { found: false, data: {}, metadata: {} }; + return { status: 'ok', found: false, data: {}, metadata: {} }; } } @@ -1898,12 +1900,13 @@ export class JavaScriptExecutor { } return { + status: 'ok', found: true, data: inspectionData, metadata: {} }; } catch { - return { found: false, data: {}, metadata: {} }; + return { status: 'ok', found: false, data: {}, metadata: {} }; } } diff --git a/packages/javascript-kernel/src/runtime_evaluator.ts b/packages/javascript-kernel/src/runtime_evaluator.ts index befd647..9bef31e 100644 --- a/packages/javascript-kernel/src/runtime_evaluator.ts +++ b/packages/javascript-kernel/src/runtime_evaluator.ts @@ -131,26 +131,14 @@ export class JavaScriptRuntimeEvaluator { cursorPos: number, detailLevel: KernelMessage.IInspectRequestMsg['content']['detail_level'] ): KernelMessage.IInspectReplyMsg['content'] { - const result = this._executor.inspect(code, cursorPos, detailLevel); - - return { - status: 'ok', - found: result.found, - data: result.data, - metadata: result.metadata - }; + return this._executor.inspect(code, cursorPos, detailLevel); } /** * Check whether the provided code is complete. */ isComplete(code: string): KernelMessage.IIsCompleteReplyMsg['content'] { - const result = this._executor.isComplete(code); - - return { - status: result.status, - indent: result.indent || '' - }; + return this._executor.isComplete(code); } /** From 6b761b4961c8139d8f753cdc079e759829de6fa1 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Sat, 21 Feb 2026 10:45:23 +0100 Subject: [PATCH 15/20] cleanup --- packages/javascript-kernel/package.json | 1 - packages/javascript-kernel/src/executor.ts | 204 +++++------------- .../javascript-kernel/src/runtime_backends.ts | 194 +++++++---------- yarn.lock | 3 +- 4 files changed, 125 insertions(+), 277 deletions(-) diff --git a/packages/javascript-kernel/package.json b/packages/javascript-kernel/package.json index f704d89..d5739e8 100644 --- a/packages/javascript-kernel/package.json +++ b/packages/javascript-kernel/package.json @@ -43,7 +43,6 @@ "watch": "tsc -b --watch" }, "dependencies": { - "@jupyterlab/coreutils": "^6.0.0", "@jupyterlite/services": "^0.7.0", "@lumino/coreutils": "^2.0.0", "astring": "^1.9.0", diff --git a/packages/javascript-kernel/src/executor.ts b/packages/javascript-kernel/src/executor.ts index eb46f08..3fb1a85 100644 --- a/packages/javascript-kernel/src/executor.ts +++ b/packages/javascript-kernel/src/executor.ts @@ -389,17 +389,7 @@ export class JavaScriptExecutor { break; case 'ExpressionStatement': - // For expression statements, we need to track them - if ( - node.expression.type === 'AssignmentExpression' && - node.expression.left.type === 'Identifier' - ) { - // Named assignment like `x = 5;` - registry.statements.push(node); - } else { - // Other expressions (function calls, etc.) - keep in order - registry.statements.push(node); - } + registry.statements.push(node); break; default: @@ -545,13 +535,7 @@ export class JavaScriptExecutor { } // Handle Error objects - if ( - this._isInstanceOfRealm( - value, - 'Error', - typeof Error === 'undefined' ? undefined : Error - ) - ) { + if (this._isInstanceOfRealm(value, 'Error')) { const errorValue = value as Error; return { 'text/plain': errorValue.stack || errorValue.toString(), @@ -564,13 +548,7 @@ export class JavaScriptExecutor { } // Handle Date objects - if ( - this._isInstanceOfRealm( - value, - 'Date', - typeof Date === 'undefined' ? undefined : Date - ) - ) { + if (this._isInstanceOfRealm(value, 'Date')) { const dateValue = value as Date; return { 'text/plain': dateValue.toISOString(), @@ -579,24 +557,12 @@ export class JavaScriptExecutor { } // Handle RegExp objects - if ( - this._isInstanceOfRealm( - value, - 'RegExp', - typeof RegExp === 'undefined' ? undefined : RegExp - ) - ) { + if (this._isInstanceOfRealm(value, 'RegExp')) { return { 'text/plain': (value as RegExp).toString() }; } // Handle Map - if ( - this._isInstanceOfRealm( - value, - 'Map', - typeof Map === 'undefined' ? undefined : Map - ) - ) { + if (this._isInstanceOfRealm(value, 'Map')) { const mapValue = value as Map; const entries = Array.from(mapValue.entries()); try { @@ -610,13 +576,7 @@ export class JavaScriptExecutor { } // Handle Set - if ( - this._isInstanceOfRealm( - value, - 'Set', - typeof Set === 'undefined' ? undefined : Set - ) - ) { + if (this._isInstanceOfRealm(value, 'Set')) { const setValue = value as Set; const items = Array.from(setValue); try { @@ -656,13 +616,7 @@ export class JavaScriptExecutor { } // Handle Promise (show as pending) - if ( - this._isInstanceOfRealm( - value, - 'Promise', - typeof Promise === 'undefined' ? undefined : Promise - ) - ) { + if (this._isInstanceOfRealm(value, 'Promise')) { return { 'text/plain': 'Promise { }' }; } @@ -1354,10 +1308,7 @@ export class JavaScriptExecutor { * Checks for _toHtml, _toSvg, _toPng, _toJpeg, _toMime, inspect. */ private _getCustomMimeBundle(value: any): IMimeBundle | null { - const bundle: IMimeBundle = {}; - let hasCustomOutput = false; - - // Check for _toMime() - returns full MIME bundle + // Check for _toMime() first - returns a full MIME bundle directly. if (typeof value._toMime === 'function') { try { const mimeResult = value._toMime(); @@ -1369,96 +1320,49 @@ export class JavaScriptExecutor { } } - if (typeof value._toHtml === 'function') { - try { - const html = value._toHtml(); - if (typeof html === 'string') { - bundle['text/html'] = html; - hasCustomOutput = true; - } - } catch { - // Ignore errors - } - } - - if (typeof value._toSvg === 'function') { - try { - const svg = value._toSvg(); - if (typeof svg === 'string') { - bundle['image/svg+xml'] = svg; - hasCustomOutput = true; - } - } catch { - // Ignore errors - } - } + // 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'] + ]; - // Check for _toPng() - should return base64 string - if (typeof value._toPng === 'function') { - try { - const png = value._toPng(); - if (typeof png === 'string') { - bundle['image/png'] = png; - hasCustomOutput = true; - } - } catch { - // Ignore errors - } - } + const bundle: IMimeBundle = {}; + let hasCustomOutput = false; - // Check for _toJpeg() - should return base64 string - if (typeof value._toJpeg === 'function') { - try { - const jpeg = value._toJpeg(); - if (typeof jpeg === 'string') { - bundle['image/jpeg'] = jpeg; - hasCustomOutput = true; + 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 } - } catch { - // Ignore errors } } - if (typeof value._toMarkdown === 'function') { - try { - const md = value._toMarkdown(); - if (typeof md === 'string') { - bundle['text/markdown'] = md; - hasCustomOutput = true; - } - } catch { - // Ignore errors - } + if (!hasCustomOutput) { + return null; } - if (typeof value._toLatex === 'function') { + // Add text/plain representation using inspect() if available. + if (typeof value.inspect === 'function') { try { - const latex = value._toLatex(); - if (typeof latex === 'string') { - bundle['text/latex'] = latex; - hasCustomOutput = true; - } + bundle['text/plain'] = value.inspect(); } catch { - // Ignore errors - } - } - - // Add text/plain representation - if (hasCustomOutput) { - // Use custom inspect() if available, otherwise use toString() - 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; + } else { + bundle['text/plain'] = String(value); } - return null; + return bundle; } /** @@ -1466,16 +1370,8 @@ export class JavaScriptExecutor { */ private _isDOMElement(value: any): boolean { return ( - this._isInstanceOfRealm( - value, - 'HTMLElement', - typeof HTMLElement === 'undefined' ? undefined : HTMLElement - ) || - this._isInstanceOfRealm( - value, - 'SVGElement', - typeof SVGElement === 'undefined' ? undefined : SVGElement - ) + this._isInstanceOfRealm(value, 'HTMLElement') || + this._isInstanceOfRealm(value, 'SVGElement') ); } @@ -1484,11 +1380,7 @@ export class JavaScriptExecutor { */ private _getDOMElementMimeBundle(element: any): IMimeBundle { const isCanvasElement = - this._isInstanceOfRealm( - element, - 'HTMLCanvasElement', - typeof HTMLCanvasElement === 'undefined' ? undefined : HTMLCanvasElement - ) || + this._isInstanceOfRealm(element, 'HTMLCanvasElement') || (typeof element?.toDataURL === 'function' && typeof element?.getContext === 'function'); @@ -1524,16 +1416,16 @@ export class JavaScriptExecutor { /** * Check `instanceof` against runtime-realm constructors when available. + * + * Looks up the constructor by name in both the runtime scope and + * `globalThis`, so callers don't need to pass a fallback constructor. */ - private _isInstanceOfRealm( - value: any, - ctorName: string, - fallbackCtor?: any - ): boolean { + private _isInstanceOfRealm(value: any, ctorName: string): boolean { if (value === null || value === undefined) { return false; } + // Check against the runtime scope constructor (e.g. iframe window). const scopeCtor = this._globalScope?.[ctorName]; if (typeof scopeCtor === 'function') { try { @@ -1545,9 +1437,11 @@ export class JavaScriptExecutor { } } - if (typeof fallbackCtor === 'function') { + // Fall back to the current realm's globalThis constructor. + const globalCtor = (globalThis as Record)[ctorName]; + if (typeof globalCtor === 'function') { try { - return value instanceof fallbackCtor; + return value instanceof globalCtor; } catch { return false; } diff --git a/packages/javascript-kernel/src/runtime_backends.ts b/packages/javascript-kernel/src/runtime_backends.ts index 2f48310..3fc9b68 100644 --- a/packages/javascript-kernel/src/runtime_backends.ts +++ b/packages/javascript-kernel/src/runtime_backends.ts @@ -49,17 +49,12 @@ export interface IRuntimeBackend { } /** - * Runtime backend that executes code in a hidden iframe through Comlink. + * Base class providing shared Comlink proxy logic for runtime backends. + * + * Subclasses must set `_remote` during initialization and call + * `_ready.resolve()` / `_ready.reject()` to signal readiness. */ -export class IFrameRuntimeBackend implements IRuntimeBackend { - /** - * Instantiate a new iframe runtime backend. - */ - constructor(options: IFrameRuntimeBackend.IOptions) { - this._options = options; - void this._init(); - } - +abstract class AbstractRuntimeBackend implements IRuntimeBackend { /** * A promise that resolves when the runtime is initialized. */ @@ -67,40 +62,10 @@ export class IFrameRuntimeBackend implements IRuntimeBackend { return this._ready.promise; } - /** - * The iframe used by the runtime backend. - */ - get iframe(): HTMLIFrameElement | null { - return this._iframe; - } - - /** - * Dispose iframe resources. - */ - dispose(): void { - this._ready.reject(new Error('IFrame runtime disposed')); - - if (this._remote) { - void this._remote.dispose().catch(() => undefined); - this._remote[Comlink.releaseProxy](); - this._remote = null; - } - - this._iframe?.remove(); - this._iframe = null; - - if (this._container) { - this._container.remove(); - this._container = null; - } - - this._outputProxy = null; - this._globalScope = null; - this._executor = null; - } + abstract dispose(): void; /** - * Execute code inside the iframe runtime. + * Execute code via the remote runtime API. */ async execute( code: string, @@ -111,7 +76,7 @@ export class IFrameRuntimeBackend implements IRuntimeBackend { } /** - * Complete code inside the iframe runtime. + * Complete code via the remote runtime API. */ async complete( code: string, @@ -122,7 +87,7 @@ export class IFrameRuntimeBackend implements IRuntimeBackend { } /** - * Inspect code inside the iframe runtime. + * Inspect code via the remote runtime API. */ async inspect( code: string, @@ -134,7 +99,7 @@ export class IFrameRuntimeBackend implements IRuntimeBackend { } /** - * Check code completeness inside the iframe runtime. + * Check code completeness via the remote runtime API. */ async isComplete( code: string @@ -143,6 +108,67 @@ export class IFrameRuntimeBackend implements IRuntimeBackend { return this._getRemote().isComplete(code); } + /** + * Return remote runtime API or throw when not initialized. + */ + private _getRemote(): Comlink.Remote { + if (!this._remote) { + throw new Error(`${this._runtimeLabel} runtime is not initialized`); + } + return this._remote; + } + + /** Human-readable label used in error messages. */ + protected abstract readonly _runtimeLabel: string; + protected _ready = new PromiseDelegate(); + protected _remote: Comlink.Remote | null = null; +} + +/** + * Runtime backend that executes code in a hidden iframe through Comlink. + */ +export class IFrameRuntimeBackend extends AbstractRuntimeBackend { + /** + * Instantiate a new iframe runtime backend. + */ + constructor(options: IFrameRuntimeBackend.IOptions) { + super(); + this._options = options; + void this._init(); + } + + /** + * The iframe used by the runtime backend. + */ + get iframe(): HTMLIFrameElement | null { + return this._iframe; + } + + /** + * Dispose iframe resources. + */ + dispose(): void { + this._ready.reject(new Error('IFrame runtime disposed')); + + if (this._remote) { + void this._remote.dispose().catch(() => undefined); + this._remote[Comlink.releaseProxy](); + this._remote = null; + } + + this._iframe?.remove(); + this._iframe = null; + + if (this._container) { + this._container.remove(); + this._container = null; + } + + this._outputProxy = null; + this._globalScope = null; + this._executor = null; + } + /** * Initialize iframe and remote runtime API. */ @@ -252,19 +278,9 @@ export class IFrameRuntimeBackend implements IRuntimeBackend { } } - /** - * Return remote runtime API or throw when not initialized. - */ - private _getRemote(): Comlink.Remote { - if (!this._remote) { - throw new Error('IFrame runtime is not initialized'); - } - return this._remote; - } + protected readonly _runtimeLabel = 'IFrame'; private _options: IFrameRuntimeBackend.IOptions; - private _ready = new PromiseDelegate(); - private _remote: Comlink.Remote | null = null; private _iframe: HTMLIFrameElement | null = null; private _container: HTMLDivElement | null = null; private _outputProxy: RuntimeOutputCallback | null = null; @@ -304,11 +320,12 @@ export namespace IFrameRuntimeBackend { /** * Runtime backend that executes code in a dedicated web worker. */ -export class WorkerRuntimeBackend implements IRuntimeBackend { +export class WorkerRuntimeBackend extends AbstractRuntimeBackend { /** * Instantiate a new worker runtime backend. */ constructor(options: WorkerRuntimeBackend.IOptions) { + super(); this._options = options; if (typeof Worker === 'undefined') { @@ -344,13 +361,6 @@ export class WorkerRuntimeBackend implements IRuntimeBackend { void this._init(); } - /** - * A promise that resolves when the runtime is initialized. - */ - get ready(): Promise { - return this._ready.promise; - } - /** * Dispose worker resources. */ @@ -368,50 +378,6 @@ export class WorkerRuntimeBackend implements IRuntimeBackend { this._outputProxy = null; } - /** - * Execute code inside the worker runtime. - */ - async execute( - code: string, - executionCount: number - ): Promise { - await this.ready; - return this._getRemote().execute(code, executionCount); - } - - /** - * Complete code inside the worker runtime. - */ - async complete( - code: string, - cursorPos: number - ): Promise { - await this.ready; - return this._getRemote().complete(code, cursorPos); - } - - /** - * Inspect code inside the worker runtime. - */ - async inspect( - code: string, - cursorPos: number, - detailLevel: KernelMessage.IInspectRequestMsg['content']['detail_level'] - ): Promise { - await this.ready; - return this._getRemote().inspect(code, cursorPos, detailLevel); - } - - /** - * Check code completeness inside the worker runtime. - */ - async isComplete( - code: string - ): Promise { - await this.ready; - return this._getRemote().isComplete(code); - } - /** * Initialize remote worker API and execute optional initialization hook. */ @@ -457,21 +423,11 @@ export class WorkerRuntimeBackend implements IRuntimeBackend { this._ready.reject(error); } - /** - * Return remote runtime API or throw when not initialized. - */ - private _getRemote(): Comlink.Remote { - if (!this._remote) { - throw new Error('Worker runtime is not initialized'); - } - return this._remote; - } + protected readonly _runtimeLabel = 'Worker'; private _options: WorkerRuntimeBackend.IOptions; private _worker: Worker | null = null; - private _remote: Comlink.Remote | null = null; private _outputProxy: RuntimeOutputCallback | null = null; - private _ready = new PromiseDelegate(); static readonly STARTUP_TIMEOUT_MS = 10000; } diff --git a/yarn.lock b/yarn.lock index ac2af03..f11f8fa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2886,7 +2886,7 @@ __metadata: languageName: node linkType: hard -"@jupyterlab/coreutils@npm:^6.0.0, @jupyterlab/coreutils@npm:^6.5.0, @jupyterlab/coreutils@npm:~6.5.0": +"@jupyterlab/coreutils@npm:^6.5.0, @jupyterlab/coreutils@npm:~6.5.0": version: 6.5.0 resolution: "@jupyterlab/coreutils@npm:6.5.0" dependencies: @@ -3385,7 +3385,6 @@ __metadata: dependencies: "@babel/core": ^7.11.6 "@babel/preset-env": ^7.12.1 - "@jupyterlab/coreutils": ^6.0.0 "@jupyterlab/testutils": ~4.5.0 "@jupyterlite/services": ^0.7.0 "@lumino/coreutils": ^2.0.0 From b423b8f279a3354dbcb7522a505692ebdf740df7 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Thu, 26 Feb 2026 09:57:14 +0100 Subject: [PATCH 16/20] fixes --- .../javascript-kernel-extension/package.json | 4 +- packages/javascript-kernel/package.json | 4 +- packages/javascript-kernel/src/display.ts | 15 +- packages/javascript-kernel/src/executor.ts | 139 +++++-- .../javascript-kernel/src/runtime_backends.ts | 68 +++- .../src/runtime_evaluator.ts | 18 +- .../javascript-kernel/src/runtime_protocol.ts | 6 +- .../javascript-kernel/src/runtime_remote.ts | 14 +- yarn.lock | 357 +++++++++++++++++- 9 files changed, 546 insertions(+), 79 deletions(-) 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/package.json b/packages/javascript-kernel/package.json index d5739e8..b3a4b74 100644 --- a/packages/javascript-kernel/package.json +++ b/packages/javascript-kernel/package.json @@ -43,8 +43,10 @@ "watch": "tsc -b --watch" }, "dependencies": { + "@jupyterlab/coreutils": "^6.5.5", + "@jupyterlab/nbformat": "^4.5.0", "@jupyterlite/services": "^0.7.0", - "@lumino/coreutils": "^2.0.0", + "@lumino/coreutils": "^2.2.2", "astring": "^1.9.0", "comlink": "^4.3.1", "meriyah": "^4.3.9" diff --git a/packages/javascript-kernel/src/display.ts b/packages/javascript-kernel/src/display.ts index a2212fd..a1220ea 100644 --- a/packages/javascript-kernel/src/display.ts +++ b/packages/javascript-kernel/src/display.ts @@ -2,13 +2,7 @@ // Distributed under the terms of the Modified BSD License. import type { KernelMessage } from '@jupyterlab/services'; - -/** - * MIME bundle for rich display. - */ -export interface IMimeBundle { - [key: string]: any; -} +import type { IMimeBundle } from '@jupyterlab/nbformat'; /** * Display request from display(). @@ -78,7 +72,12 @@ export class DisplayHelper { * display('my-id').html('
...
') */ display(id?: string): DisplayHelper { - return new DisplayHelper(id); + const child = new DisplayHelper(id); + child.setCallbacks({ + onDisplay: this._displayCallback, + onClear: this._clearCallback + }); + return child; } /** diff --git a/packages/javascript-kernel/src/executor.ts b/packages/javascript-kernel/src/executor.ts index 3fb1a85..cae987a 100644 --- a/packages/javascript-kernel/src/executor.ts +++ b/packages/javascript-kernel/src/executor.ts @@ -6,14 +6,9 @@ import type { KernelMessage } from '@jupyterlab/services'; import { parseScript } from 'meriyah'; import { generate } from 'astring'; -import { IMimeBundle } from './display'; +import type { IMimeBundle } from '@jupyterlab/nbformat'; -export { - IMimeBundle, - IDisplayData, - IDisplayCallbacks, - DisplayHelper -} from './display'; +export { IDisplayData, IDisplayCallbacks, DisplayHelper } from './display'; /** * Configuration for magic imports. @@ -501,8 +496,10 @@ export class JavaScriptExecutor { // Handle primitives if (typeof value === 'string') { - // Check if it looks like HTML - if (value.trim().startsWith('<') && value.trim().endsWith('>')) { + // 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 @@ -746,23 +743,17 @@ export class JavaScriptExecutor { const codeLine = lines[lineIndex]; - // Only match if cursor is at the end of the line - if (cursorPosInLine !== codeLine.length) { - return { - matches: [], - cursorStart: cursorPos, - cursorEnd: cursorPos - }; - } - - const lineRes = this.completeLine(codeLine); + 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, + cursorEnd: cursorPos + cursorTail.length, status: lineRes.status || 'ok' }; } @@ -1151,35 +1142,103 @@ export class JavaScriptExecutor { * Transform import source with magic imports. */ private _transformImportSource(source: string): string { - const noMagicStarts = ['http://', 'https://', 'data:', 'file://', 'blob:']; - const noEmsEnds = ['.js', '.mjs', '.cjs', '.wasm', '+esm']; - if (!this._config.magicImports.enabled) { return source; } - const baseUrl = this._config.magicImports.baseUrl.endsWith('/') - ? this._config.magicImports.baseUrl - : this._config.magicImports.baseUrl + '/'; - - const addEms = !noEmsEnds.some(end => source.endsWith(end)); - const emsExtraEnd = addEms ? (source.endsWith('/') ? '+esm' : '/+esm') : ''; - - // If the source starts with http/https, don't transform - if (noMagicStarts.some(start => source.startsWith(start))) { + // Keep absolute, relative and import-map style specifiers unchanged. + if (this._isDirectImportSource(source)) { return source; } - // If it starts with npm/ or gh/, or auto npm is disabled - if ( - ['npm/', 'gh/'].some(start => source.startsWith(start)) || + const { path: sourcePath, suffix } = this._splitImportSourceSuffix(source); + + const transformedPath = + ['npm/', 'gh/'].some(start => sourcePath.startsWith(start)) || !this._config.magicImports.enableAutoNpm - ) { - return `${baseUrl}${source}${emsExtraEnd}`; + ? 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: '' }; } - // Auto-prefix with npm/ - return `${baseUrl}npm/${source}${emsExtraEnd}`; + 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}`; + } } /** @@ -1659,7 +1718,7 @@ export class JavaScriptExecutor { expression: string, value: any, detailLevel: number - ): IMimeBundle { + ): IInspectResult['data'] { const lines: string[] = []; // Type information diff --git a/packages/javascript-kernel/src/runtime_backends.ts b/packages/javascript-kernel/src/runtime_backends.ts index 3fc9b68..1116010 100644 --- a/packages/javascript-kernel/src/runtime_backends.ts +++ b/packages/javascript-kernel/src/runtime_backends.ts @@ -2,6 +2,7 @@ // Distributed under the terms of the Modified BSD License. import type { KernelMessage } from '@jupyterlab/services'; +import { PageConfig } from '@jupyterlab/coreutils'; import { PromiseDelegate } from '@lumino/coreutils'; @@ -22,6 +23,7 @@ import type { */ export interface IRuntimeBackendOptions { onOutput: RuntimeOutputHandler; + baseUrl?: string; } /** @@ -189,20 +191,41 @@ export class IFrameRuntimeBackend extends AbstractRuntimeBackend { `; - this._container.appendChild(this._iframe); + const iframe = this._iframe; + const iframeLoad = new Promise((resolve, reject) => { + let settled = false; - await new Promise((resolve, reject) => { - if (!this._iframe) { - reject(new Error('IFrame runtime is not initialized')); - return; - } + const cleanup = (): void => { + iframe.onload = null; + iframe.onerror = null; + }; - this._iframe.onload = () => resolve(); - this._iframe.onerror = () => { + iframe.onload = () => { + if (settled) { + return; + } + settled = true; + cleanup(); + resolve(); + }; + iframe.onerror = () => { + if (settled) { + return; + } + settled = true; + cleanup(); reject(new Error('IFrame runtime failed to load')); }; }); + this._container.appendChild(iframe); + + await withTimeout( + iframeLoad, + IFrameRuntimeBackend.STARTUP_TIMEOUT_MS, + 'IFrame runtime failed to load' + ); + if (!this._iframe?.contentWindow) { throw new Error('IFrame window not available'); } @@ -242,7 +265,12 @@ export class IFrameRuntimeBackend extends AbstractRuntimeBackend { } await withTimeout( - remote.initialize(activeOutputProxy), + remote.initialize( + { + baseUrl: resolveBaseUrl(this._options.baseUrl) + }, + activeOutputProxy + ), IFrameRuntimeBackend.STARTUP_TIMEOUT_MS, 'IFrame runtime failed to initialize' ); @@ -392,7 +420,12 @@ export class WorkerRuntimeBackend extends AbstractRuntimeBackend { try { await withTimeout( - remote.initialize(outputProxy), + remote.initialize( + { + baseUrl: resolveBaseUrl(this._options.baseUrl) + }, + outputProxy + ), WorkerRuntimeBackend.STARTUP_TIMEOUT_MS, 'Worker runtime failed to initialize' ); @@ -479,3 +512,18 @@ async function withTimeout( ); }); } + +/** + * Resolve the runtime base URL with JupyterLab PageConfig fallback. + */ +function resolveBaseUrl(baseUrl?: string): string { + if (typeof baseUrl === 'string' && baseUrl.length > 0) { + return baseUrl; + } + + try { + return PageConfig.getBaseUrl(); + } catch { + return '/'; + } +} diff --git a/packages/javascript-kernel/src/runtime_evaluator.ts b/packages/javascript-kernel/src/runtime_evaluator.ts index 9bef31e..c091c3e 100644 --- a/packages/javascript-kernel/src/runtime_evaluator.ts +++ b/packages/javascript-kernel/src/runtime_evaluator.ts @@ -164,7 +164,23 @@ export class JavaScriptRuntimeEvaluator { warn: scopeConsole.warn }; - const toText = (args: any[]) => args.join(' ') + '\n'; + const toText = (args: any[]) => { + const text = args + .map(arg => { + if (typeof arg === 'string') { + return arg; + } + + try { + return String(arg); + } catch { + return '[Unprintable value]'; + } + }) + .join(' '); + + return `${text}\n`; + }; scopeConsole.log = (...args: any[]) => { this._onOutput({ diff --git a/packages/javascript-kernel/src/runtime_protocol.ts b/packages/javascript-kernel/src/runtime_protocol.ts index 6439cb2..b5f1fee 100644 --- a/packages/javascript-kernel/src/runtime_protocol.ts +++ b/packages/javascript-kernel/src/runtime_protocol.ts @@ -2,6 +2,7 @@ // Distributed under the terms of the Modified BSD License. import type { KernelMessage } from '@jupyterlab/services'; +import type { IWorkerKernel } from '@jupyterlite/services'; /** * Supported runtime backends for the JavaScript kernel. @@ -57,7 +58,10 @@ export type RuntimeOutputCallback = ( * Runtime API exposed from iframe and worker contexts over Comlink. */ export interface IRemoteRuntimeApi { - initialize(onOutput: RuntimeOutputCallback): Promise; + initialize( + options: IWorkerKernel.IOptions, + onOutput: RuntimeOutputCallback + ): Promise; execute( code: string, executionCount: number diff --git a/packages/javascript-kernel/src/runtime_remote.ts b/packages/javascript-kernel/src/runtime_remote.ts index 05a0e96..68aafc1 100644 --- a/packages/javascript-kernel/src/runtime_remote.ts +++ b/packages/javascript-kernel/src/runtime_remote.ts @@ -5,7 +5,6 @@ import { JavaScriptRuntimeEvaluator } from './runtime_evaluator'; import type { JavaScriptExecutor } from './executor'; import type { IRemoteRuntimeApi, - RuntimeOutputCallback, RuntimeOutputMessage } from './runtime_protocol'; @@ -26,7 +25,7 @@ export function createRemoteRuntimeApi( }; const emitOutput = ( - callback: RuntimeOutputCallback, + callback: Parameters[1], message: RuntimeOutputMessage ): void => { void Promise.resolve(callback(makeCloneSafe(message))).catch(() => { @@ -35,7 +34,13 @@ export function createRemoteRuntimeApi( }; return { - async initialize(onOutput: RuntimeOutputCallback): Promise { + async initialize( + options: Parameters[0], + onOutput: Parameters[1] + ): Promise { + if (typeof options.baseUrl !== 'string') { + throw new Error('Runtime baseUrl is required'); + } evaluator?.dispose(); evaluator = new JavaScriptRuntimeEvaluator({ globalScope, @@ -79,8 +84,7 @@ export function createRemoteRuntimeApi( function makeCloneSafe(value: T): T { if (typeof structuredClone === 'function') { try { - structuredClone(value); - return value; + return structuredClone(value); } catch { // fall through to sanitization } diff --git a/yarn.lock b/yarn.lock index f11f8fa..a6527a9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1945,6 +1945,15 @@ __metadata: languageName: node linkType: hard +"@codemirror/state@npm:^6.5.4": + version: 6.5.4 + resolution: "@codemirror/state@npm:6.5.4" + dependencies: + "@marijn/find-cluster-break": ^1.0.0 + checksum: f5fec77bbfd10efc157fc93cf725fb55e4e7d2cf4919bb9e2e43ed9d86aa0f0ac423c2625da99710321e6073bce5f391f2d565db137ef2597dce7d038cfcc2ba + languageName: node + linkType: hard + "@codemirror/view@npm:^6.0.0, @codemirror/view@npm:^6.17.0, @codemirror/view@npm:^6.23.0": version: 6.26.3 resolution: "@codemirror/view@npm:6.26.3" @@ -2700,6 +2709,34 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/application@npm:^4.5.5": + version: 4.5.5 + resolution: "@jupyterlab/application@npm:4.5.5" + dependencies: + "@fortawesome/fontawesome-free": ^5.12.0 + "@jupyterlab/apputils": ^4.6.5 + "@jupyterlab/coreutils": ^6.5.5 + "@jupyterlab/docregistry": ^4.5.5 + "@jupyterlab/rendermime": ^4.5.5 + "@jupyterlab/rendermime-interfaces": ^3.13.5 + "@jupyterlab/services": ^7.5.5 + "@jupyterlab/statedb": ^4.5.5 + "@jupyterlab/translation": ^4.5.5 + "@jupyterlab/ui-components": ^4.5.5 + "@lumino/algorithm": ^2.0.4 + "@lumino/application": ^2.4.8 + "@lumino/commands": ^2.3.3 + "@lumino/coreutils": ^2.2.2 + "@lumino/disposable": ^2.1.5 + "@lumino/messaging": ^2.0.4 + "@lumino/polling": ^2.1.5 + "@lumino/properties": ^2.0.4 + "@lumino/signaling": ^2.1.5 + "@lumino/widgets": ^2.7.5 + checksum: 70df5afff4f0c84f1bdaa521711ae0b6b82a009b7c7b7f60180142e633c3c5397e9ceb324a74d797edd104d221d8fd6bf10306faded96a360e508780ca2a3d61 + languageName: node + linkType: hard + "@jupyterlab/apputils@npm:^4.6.0": version: 4.6.0 resolution: "@jupyterlab/apputils@npm:4.6.0" @@ -2729,6 +2766,35 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/apputils@npm:^4.6.5": + version: 4.6.5 + resolution: "@jupyterlab/apputils@npm:4.6.5" + dependencies: + "@jupyterlab/coreutils": ^6.5.5 + "@jupyterlab/observables": ^5.5.5 + "@jupyterlab/rendermime-interfaces": ^3.13.5 + "@jupyterlab/services": ^7.5.5 + "@jupyterlab/settingregistry": ^4.5.5 + "@jupyterlab/statedb": ^4.5.5 + "@jupyterlab/statusbar": ^4.5.5 + "@jupyterlab/translation": ^4.5.5 + "@jupyterlab/ui-components": ^4.5.5 + "@lumino/algorithm": ^2.0.4 + "@lumino/commands": ^2.3.3 + "@lumino/coreutils": ^2.2.2 + "@lumino/disposable": ^2.1.5 + "@lumino/domutils": ^2.0.4 + "@lumino/messaging": ^2.0.4 + "@lumino/signaling": ^2.1.5 + "@lumino/virtualdom": ^2.0.4 + "@lumino/widgets": ^2.7.5 + "@types/react": ^18.0.26 + react: ^18.2.0 + sanitize-html: ~2.12.1 + checksum: f1459a948bded9ec1bf40193f6a2c7cefc2483c619eb6e6008a78fd0f1bfffc76053afbd3b844ef5e3d94130b41218f20f5782541c37a70c475d87d16b04e745 + languageName: node + linkType: hard + "@jupyterlab/attachments@npm:^4.5.0": version: 4.5.0 resolution: "@jupyterlab/attachments@npm:4.5.0" @@ -2743,22 +2809,22 @@ __metadata: languageName: node linkType: hard -"@jupyterlab/builder@npm:^4.5.0": - version: 4.5.0 - resolution: "@jupyterlab/builder@npm:4.5.0" +"@jupyterlab/builder@npm:^4.5.5": + version: 4.5.5 + resolution: "@jupyterlab/builder@npm:4.5.5" dependencies: "@lumino/algorithm": ^2.0.4 - "@lumino/application": ^2.4.5 + "@lumino/application": ^2.4.8 "@lumino/commands": ^2.3.3 "@lumino/coreutils": ^2.2.2 "@lumino/disposable": ^2.1.5 "@lumino/domutils": ^2.0.4 - "@lumino/dragdrop": ^2.1.7 + "@lumino/dragdrop": ^2.1.8 "@lumino/messaging": ^2.0.4 "@lumino/properties": ^2.0.4 "@lumino/signaling": ^2.1.5 "@lumino/virtualdom": ^2.0.4 - "@lumino/widgets": ^2.7.2 + "@lumino/widgets": ^2.7.5 ajv: ^8.12.0 commander: ^9.4.1 css-loader: ^6.7.1 @@ -2780,7 +2846,7 @@ __metadata: worker-loader: ^3.0.2 bin: build-labextension: lib/build-labextension.js - checksum: 2efc4b3efd31a263e49c5908a15ee05509005cf13659ff3d00abeff25d0d3b46f05813b711ddd90d322d5fec98c9159d4fa5b0abc810f7927c08ebf477c5a7a7 + checksum: 1008335476afc2e95df3e163bee2832bcecb70c3e7053838e91b680395351f0c62a509d51850a5a2cdf1665157a2059195c0a238b9815ecefad6867f8db9cf7a languageName: node linkType: hard @@ -2844,6 +2910,30 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/codeeditor@npm:^4.5.5": + version: 4.5.5 + resolution: "@jupyterlab/codeeditor@npm:4.5.5" + dependencies: + "@codemirror/state": ^6.5.4 + "@jupyter/ydoc": ^3.1.0 + "@jupyterlab/apputils": ^4.6.5 + "@jupyterlab/coreutils": ^6.5.5 + "@jupyterlab/nbformat": ^4.5.5 + "@jupyterlab/observables": ^5.5.5 + "@jupyterlab/statusbar": ^4.5.5 + "@jupyterlab/translation": ^4.5.5 + "@jupyterlab/ui-components": ^4.5.5 + "@lumino/coreutils": ^2.2.2 + "@lumino/disposable": ^2.1.5 + "@lumino/dragdrop": ^2.1.8 + "@lumino/messaging": ^2.0.4 + "@lumino/signaling": ^2.1.5 + "@lumino/widgets": ^2.7.5 + react: ^18.2.0 + checksum: b74ba6c6b24924f2fd63d35e25ebbc7ddbfa32e8ae77763b01b3dea647d141ff796b8639b9e1b1221c8dd0646ea9837e0d32f3936e41918213173fb613f887aa + languageName: node + linkType: hard + "@jupyterlab/codemirror@npm:^4.5.0": version: 4.5.0 resolution: "@jupyterlab/codemirror@npm:4.5.0" @@ -2900,6 +2990,20 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/coreutils@npm:^6.5.5": + version: 6.5.5 + resolution: "@jupyterlab/coreutils@npm:6.5.5" + dependencies: + "@lumino/coreutils": ^2.2.2 + "@lumino/disposable": ^2.1.5 + "@lumino/signaling": ^2.1.5 + minimist: ~1.2.0 + path-browserify: ^1.0.0 + url-parse: ~1.5.4 + checksum: 044e7639afacb53cfcb75ab5e9020a9ee042b2ef13ff2906531a774f31177bdcec3380a7e60313c8ee632f035e825e6900b8e0f3a54c88a6efa05560a6f55275 + languageName: node + linkType: hard + "@jupyterlab/docmanager@npm:^4.5.0": version: 4.5.0 resolution: "@jupyterlab/docmanager@npm:4.5.0" @@ -2952,6 +3056,32 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/docregistry@npm:^4.5.5": + version: 4.5.5 + resolution: "@jupyterlab/docregistry@npm:4.5.5" + dependencies: + "@jupyter/ydoc": ^3.1.0 + "@jupyterlab/apputils": ^4.6.5 + "@jupyterlab/codeeditor": ^4.5.5 + "@jupyterlab/coreutils": ^6.5.5 + "@jupyterlab/observables": ^5.5.5 + "@jupyterlab/rendermime": ^4.5.5 + "@jupyterlab/rendermime-interfaces": ^3.13.5 + "@jupyterlab/services": ^7.5.5 + "@jupyterlab/translation": ^4.5.5 + "@jupyterlab/ui-components": ^4.5.5 + "@lumino/algorithm": ^2.0.4 + "@lumino/coreutils": ^2.2.2 + "@lumino/disposable": ^2.1.5 + "@lumino/messaging": ^2.0.4 + "@lumino/properties": ^2.0.4 + "@lumino/signaling": ^2.1.5 + "@lumino/widgets": ^2.7.5 + react: ^18.2.0 + checksum: fc07e1dbaabdea83638e1d737b1f33d77c016b07cb1bb6c7358d5eb83e0ec9cae7ab44057d9ec391ebfc5590e37807f4ff94e62524cf06f23b798578ac4c0194 + languageName: node + linkType: hard + "@jupyterlab/documentsearch@npm:^4.5.0": version: 4.5.0 resolution: "@jupyterlab/documentsearch@npm:4.5.0" @@ -3073,6 +3203,15 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/nbformat@npm:^4.5.5": + version: 4.5.5 + resolution: "@jupyterlab/nbformat@npm:4.5.5" + dependencies: + "@lumino/coreutils": ^2.2.2 + checksum: 3db7d9fa500161bd1d0a5abcd7c05f7a45abc6180ccab0023bc3f80cfdc6a354de61970f67c6835de87d2ad40c520d59e6cc3bf27834dc8f18d0dc867f130b3e + languageName: node + linkType: hard + "@jupyterlab/notebook@npm:^4.5.0": version: 4.5.0 resolution: "@jupyterlab/notebook@npm:4.5.0" @@ -3125,6 +3264,19 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/observables@npm:^5.5.5": + version: 5.5.5 + resolution: "@jupyterlab/observables@npm:5.5.5" + dependencies: + "@lumino/algorithm": ^2.0.4 + "@lumino/coreutils": ^2.2.2 + "@lumino/disposable": ^2.1.5 + "@lumino/messaging": ^2.0.4 + "@lumino/signaling": ^2.1.5 + checksum: 107880c918f2c73ac8e4d5bcbedf2fa221607c50752e3f1d930f4054a0470603f7987083d875f179eb12ac83e0a179466c616ecf4102ec04024ddd5422e89563 + languageName: node + linkType: hard + "@jupyterlab/outputarea@npm:^4.5.0": version: 4.5.0 resolution: "@jupyterlab/outputarea@npm:4.5.0" @@ -3157,6 +3309,16 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/rendermime-interfaces@npm:^3.13.5": + version: 3.13.5 + resolution: "@jupyterlab/rendermime-interfaces@npm:3.13.5" + dependencies: + "@lumino/coreutils": ^1.11.0 || ^2.2.2 + "@lumino/widgets": ^1.37.2 || ^2.7.5 + checksum: b128c5babd0728383f8e35af16aa76cd63e8a4e922f74643298baf647419885f1c7cacf1f7bcd6f6094ba5e7d7d0b20900d1a127c297c1c8df7d6a78f9ee6463 + languageName: node + linkType: hard + "@jupyterlab/rendermime@npm:^4.5.0": version: 4.5.0 resolution: "@jupyterlab/rendermime@npm:4.5.0" @@ -3177,6 +3339,26 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/rendermime@npm:^4.5.5": + version: 4.5.5 + resolution: "@jupyterlab/rendermime@npm:4.5.5" + dependencies: + "@jupyterlab/apputils": ^4.6.5 + "@jupyterlab/coreutils": ^6.5.5 + "@jupyterlab/nbformat": ^4.5.5 + "@jupyterlab/observables": ^5.5.5 + "@jupyterlab/rendermime-interfaces": ^3.13.5 + "@jupyterlab/services": ^7.5.5 + "@jupyterlab/translation": ^4.5.5 + "@lumino/coreutils": ^2.2.2 + "@lumino/messaging": ^2.0.4 + "@lumino/signaling": ^2.1.5 + "@lumino/widgets": ^2.7.5 + lodash.escape: ^4.0.1 + checksum: 9ce76bbfa007830ad622eaf421a74d3cd8f4f9cd72d489cf70fa7141d5dbe009d8bd8991390afc3efe898574ae494c0d1a73a373c16e0279829886829f4b4d81 + languageName: node + linkType: hard + "@jupyterlab/services@npm:^7.5.0, @jupyterlab/services@npm:~7.5.0": version: 7.5.0 resolution: "@jupyterlab/services@npm:7.5.0" @@ -3196,6 +3378,25 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/services@npm:^7.5.5": + version: 7.5.5 + resolution: "@jupyterlab/services@npm:7.5.5" + dependencies: + "@jupyter/ydoc": ^3.1.0 + "@jupyterlab/coreutils": ^6.5.5 + "@jupyterlab/nbformat": ^4.5.5 + "@jupyterlab/settingregistry": ^4.5.5 + "@jupyterlab/statedb": ^4.5.5 + "@lumino/coreutils": ^2.2.2 + "@lumino/disposable": ^2.1.5 + "@lumino/polling": ^2.1.5 + "@lumino/properties": ^2.0.4 + "@lumino/signaling": ^2.1.5 + ws: ^8.11.0 + checksum: 54b0483c72835367085dc03f2066f8a4458f234d94b8ff16921f05821d06a742148d8977c38e0c73224dbecf6eb42b8519853884afd2a7003a515a4c1ba7fc1a + languageName: node + linkType: hard + "@jupyterlab/settingregistry@npm:^4.5.0, @jupyterlab/settingregistry@npm:~4.5.0": version: 4.5.0 resolution: "@jupyterlab/settingregistry@npm:4.5.0" @@ -3215,6 +3416,25 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/settingregistry@npm:^4.5.5": + version: 4.5.5 + resolution: "@jupyterlab/settingregistry@npm:4.5.5" + dependencies: + "@jupyterlab/nbformat": ^4.5.5 + "@jupyterlab/statedb": ^4.5.5 + "@lumino/commands": ^2.3.3 + "@lumino/coreutils": ^2.2.2 + "@lumino/disposable": ^2.1.5 + "@lumino/signaling": ^2.1.5 + "@rjsf/utils": ^5.13.4 + ajv: ^8.12.0 + json5: ^2.2.3 + peerDependencies: + react: ">=16" + checksum: 722f0d404cb56167e49bda3d4ad269a880d790adc25708d74287ab2980865e0d9c8f98bac45e4c58af7df14dfcc486916c18951d9b8f9c978d07643257be5a3a + languageName: node + linkType: hard + "@jupyterlab/statedb@npm:^4.5.0": version: 4.5.0 resolution: "@jupyterlab/statedb@npm:4.5.0" @@ -3228,6 +3448,19 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/statedb@npm:^4.5.5": + version: 4.5.5 + resolution: "@jupyterlab/statedb@npm:4.5.5" + dependencies: + "@lumino/commands": ^2.3.3 + "@lumino/coreutils": ^2.2.2 + "@lumino/disposable": ^2.1.5 + "@lumino/properties": ^2.0.4 + "@lumino/signaling": ^2.1.5 + checksum: a3bd24f190420aa631af6311ff2cb91bcd23aeebd041f3a211c30d39be4593af48795f2d9c9efd25f004310547ba001f3b1509a1b0992fba43208082ac322efe + languageName: node + linkType: hard + "@jupyterlab/statusbar@npm:^4.5.0": version: 4.5.0 resolution: "@jupyterlab/statusbar@npm:4.5.0" @@ -3244,6 +3477,22 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/statusbar@npm:^4.5.5": + version: 4.5.5 + resolution: "@jupyterlab/statusbar@npm:4.5.5" + dependencies: + "@jupyterlab/ui-components": ^4.5.5 + "@lumino/algorithm": ^2.0.4 + "@lumino/coreutils": ^2.2.2 + "@lumino/disposable": ^2.1.5 + "@lumino/messaging": ^2.0.4 + "@lumino/signaling": ^2.1.5 + "@lumino/widgets": ^2.7.5 + react: ^18.2.0 + checksum: dfe4da0b2c373e8e2c3458c720b1940e574cf2e26869319cb6018b8eddebfa4bf21b7022ba65fe1cd33049c9c139045052bbadf61a1ae749fec7490d115cec9f + languageName: node + linkType: hard + "@jupyterlab/testing@npm:^4.5.0": version: 4.5.0 resolution: "@jupyterlab/testing@npm:4.5.0" @@ -3316,6 +3565,19 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/translation@npm:^4.5.5": + version: 4.5.5 + resolution: "@jupyterlab/translation@npm:4.5.5" + dependencies: + "@jupyterlab/coreutils": ^6.5.5 + "@jupyterlab/rendermime-interfaces": ^3.13.5 + "@jupyterlab/services": ^7.5.5 + "@jupyterlab/statedb": ^4.5.5 + "@lumino/coreutils": ^2.2.2 + checksum: a8965bae1806361470c0799180df930f9d9be030d74424753c4e43effd3f2db2032be56b35d0cf77eb37663d58f71d988e14fd6d9def2fbb53571fbab981b110 + languageName: node + linkType: hard + "@jupyterlab/ui-components@npm:^4.5.0": version: 4.5.0 resolution: "@jupyterlab/ui-components@npm:4.5.0" @@ -3347,12 +3609,43 @@ __metadata: languageName: node linkType: hard +"@jupyterlab/ui-components@npm:^4.5.5": + version: 4.5.5 + resolution: "@jupyterlab/ui-components@npm:4.5.5" + dependencies: + "@jupyter/react-components": ^0.16.6 + "@jupyter/web-components": ^0.16.6 + "@jupyterlab/coreutils": ^6.5.5 + "@jupyterlab/observables": ^5.5.5 + "@jupyterlab/rendermime-interfaces": ^3.13.5 + "@jupyterlab/translation": ^4.5.5 + "@lumino/algorithm": ^2.0.4 + "@lumino/commands": ^2.3.3 + "@lumino/coreutils": ^2.2.2 + "@lumino/disposable": ^2.1.5 + "@lumino/messaging": ^2.0.4 + "@lumino/polling": ^2.1.5 + "@lumino/properties": ^2.0.4 + "@lumino/signaling": ^2.1.5 + "@lumino/virtualdom": ^2.0.4 + "@lumino/widgets": ^2.7.5 + "@rjsf/core": ^5.13.4 + "@rjsf/utils": ^5.13.4 + react: ^18.2.0 + react-dom: ^18.2.0 + typestyle: ^2.0.4 + peerDependencies: + react: ^18.2.0 + checksum: 51dd8aecaf5ced1e82418daf13e9c1f8196146bad95f4e7b59bc9810544fbecffc31354a70d15a2344eaa686a981db35f6c5e5ba341084700c57fa86b7c8ae8b + languageName: node + linkType: hard + "@jupyterlite/javascript-kernel-extension@workspace:packages/javascript-kernel-extension": version: 0.0.0-use.local resolution: "@jupyterlite/javascript-kernel-extension@workspace:packages/javascript-kernel-extension" dependencies: - "@jupyterlab/application": ^4.5.0 - "@jupyterlab/builder": ^4.5.0 + "@jupyterlab/application": ^4.5.5 + "@jupyterlab/builder": ^4.5.5 "@jupyterlite/javascript-kernel": ^0.4.0-alpha.0 "@jupyterlite/services": ^0.7.0 npm-run-all2: ^7.0.1 @@ -3385,9 +3678,11 @@ __metadata: dependencies: "@babel/core": ^7.11.6 "@babel/preset-env": ^7.12.1 + "@jupyterlab/coreutils": ^6.5.5 + "@jupyterlab/nbformat": ^4.5.0 "@jupyterlab/testutils": ~4.5.0 "@jupyterlite/services": ^0.7.0 - "@lumino/coreutils": ^2.0.0 + "@lumino/coreutils": ^2.2.2 "@types/jest": ^26.0.10 astring: ^1.9.0 comlink: ^4.3.1 @@ -3752,6 +4047,17 @@ __metadata: languageName: node linkType: hard +"@lumino/application@npm:^2.4.8": + version: 2.4.8 + resolution: "@lumino/application@npm:2.4.8" + dependencies: + "@lumino/commands": ^2.3.3 + "@lumino/coreutils": ^2.2.2 + "@lumino/widgets": ^2.7.5 + checksum: 19f607bf6ac6f2b1d3c6a1436b4f396c23fc0bb109eb3ce1b0e1dd8c7858b81781a2b31f865fd246503f69220bcefa0d2c31f259afec826c183366164acee86b + languageName: node + linkType: hard + "@lumino/collections@npm:^2.0.4": version: 2.0.4 resolution: "@lumino/collections@npm:2.0.4" @@ -3783,7 +4089,7 @@ __metadata: languageName: node linkType: hard -"@lumino/coreutils@npm:^1.11.0 || ^2.2.2, @lumino/coreutils@npm:^2.0.0, @lumino/coreutils@npm:^2.2.2": +"@lumino/coreutils@npm:^1.11.0 || ^2.2.2, @lumino/coreutils@npm:^2.2.2": version: 2.2.2 resolution: "@lumino/coreutils@npm:2.2.2" dependencies: @@ -3827,6 +4133,16 @@ __metadata: languageName: node linkType: hard +"@lumino/dragdrop@npm:^2.1.8": + version: 2.1.8 + resolution: "@lumino/dragdrop@npm:2.1.8" + dependencies: + "@lumino/coreutils": ^2.2.2 + "@lumino/disposable": ^2.1.5 + checksum: 2e772456ce911c6c4941df4213eebeb64265f7ee36f83332ac1abb01bbc1b88b84978616dbc58cf7e8cb053db2018090ad68221bc343acaf1b6dcbbe355e25ab + languageName: node + linkType: hard + "@lumino/keyboard@npm:^2.0.4": version: 2.0.4 resolution: "@lumino/keyboard@npm:2.0.4" @@ -3910,6 +4226,25 @@ __metadata: languageName: node linkType: hard +"@lumino/widgets@npm:^1.37.2 || ^2.7.5, @lumino/widgets@npm:^2.7.5": + version: 2.7.5 + resolution: "@lumino/widgets@npm:2.7.5" + dependencies: + "@lumino/algorithm": ^2.0.4 + "@lumino/commands": ^2.3.3 + "@lumino/coreutils": ^2.2.2 + "@lumino/disposable": ^2.1.5 + "@lumino/domutils": ^2.0.4 + "@lumino/dragdrop": ^2.1.8 + "@lumino/keyboard": ^2.0.4 + "@lumino/messaging": ^2.0.4 + "@lumino/properties": ^2.0.4 + "@lumino/signaling": ^2.1.5 + "@lumino/virtualdom": ^2.0.4 + checksum: 0d5ee9a04bca0fe8f48f4f8b486128acc6c67586c5c65c75f7a8c78bfef931b9bdd49ab741883427e0f2375669a5c1821e90a662c81c63b6cc9e8f5c555e2f14 + languageName: node + linkType: hard + "@marijn/find-cluster-break@npm:^1.0.0": version: 1.0.2 resolution: "@marijn/find-cluster-break@npm:1.0.2" From 67f7cbe42eb4762f2c734b2c2a154dfac5b84836 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Thu, 26 Feb 2026 10:15:46 +0100 Subject: [PATCH 17/20] lint --- packages/javascript-kernel/src/executor.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/javascript-kernel/src/executor.ts b/packages/javascript-kernel/src/executor.ts index cae987a..836d29b 100644 --- a/packages/javascript-kernel/src/executor.ts +++ b/packages/javascript-kernel/src/executor.ts @@ -499,7 +499,10 @@ export class JavaScriptExecutor { // 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('>')) { + if ( + /^<(?:[a-zA-Z][a-zA-Z0-9-]*[\s\/>]|!(?:DOCTYPE|--))/.test(trimmed) && + trimmed.endsWith('>') + ) { return { 'text/html': value, 'text/plain': value From 9a1d2504b7376d5065e66e8f2cc3902308ad3111 Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Thu, 26 Feb 2026 10:24:45 +0100 Subject: [PATCH 18/20] lint --- packages/javascript-kernel/src/executor.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/javascript-kernel/src/executor.ts b/packages/javascript-kernel/src/executor.ts index 836d29b..3330b4e 100644 --- a/packages/javascript-kernel/src/executor.ts +++ b/packages/javascript-kernel/src/executor.ts @@ -500,7 +500,7 @@ export class JavaScriptExecutor { // , ,
, etc.). Rejects non-HTML like "". const trimmed = value.trim(); if ( - /^<(?:[a-zA-Z][a-zA-Z0-9-]*[\s\/>]|!(?:DOCTYPE|--))/.test(trimmed) && + /^<(?:[a-zA-Z][a-zA-Z0-9-]*[\s/>]|!(?:DOCTYPE|--))/.test(trimmed) && trimmed.endsWith('>') ) { return { From 6270c955d0381559843a7f672872bfb0f10874cb Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Mon, 2 Mar 2026 21:54:22 +0100 Subject: [PATCH 19/20] fix display names --- README.md | 8 ++++---- packages/javascript-kernel-extension/src/index.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 2118e69..d1f8eac 100644 --- a/README.md +++ b/README.md @@ -33,20 +33,20 @@ pip uninstall jupyterlite-javascript-kernel The extension currently registers two JavaScript kernelspecs: -- `JavaScript`: +- `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 (Worker)`: +- `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 (Worker)`, APIs such as `document`, direct element access, and other main-thread-only browser APIs are unavailable. +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 mode), user code and imports execute in the runtime iframe scope. +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. diff --git a/packages/javascript-kernel-extension/src/index.ts b/packages/javascript-kernel-extension/src/index.ts index 309e68d..0ceaf1f 100644 --- a/packages/javascript-kernel-extension/src/index.ts +++ b/packages/javascript-kernel-extension/src/index.ts @@ -72,7 +72,7 @@ const kernelIFrame: JupyterFrontEndPlugin = { activate: (app: JupyterFrontEnd, kernelspecs: IKernelSpecs) => { registerKernel(kernelspecs, { name: 'javascript', - displayName: 'JavaScript', + displayName: 'JavaScript (IFrame)', runtime: 'iframe' }); } @@ -88,7 +88,7 @@ const kernelWorker: JupyterFrontEndPlugin = { activate: (app: JupyterFrontEnd, kernelspecs: IKernelSpecs) => { registerKernel(kernelspecs, { name: 'javascript-worker', - displayName: 'JavaScript (Worker)', + displayName: 'JavaScript (Web Worker)', runtime: 'worker' }); } From a726c70b0bcbad5515a5ede632f258040a29ed6d Mon Sep 17 00:00:00 2001 From: Jeremy Tuloup Date: Tue, 3 Mar 2026 20:34:58 +0100 Subject: [PATCH 20/20] console fixes --- .../src/runtime_evaluator.ts | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/javascript-kernel/src/runtime_evaluator.ts b/packages/javascript-kernel/src/runtime_evaluator.ts index c091c3e..7492625 100644 --- a/packages/javascript-kernel/src/runtime_evaluator.ts +++ b/packages/javascript-kernel/src/runtime_evaluator.ts @@ -161,7 +161,11 @@ export class JavaScriptRuntimeEvaluator { log: scopeConsole.log, info: scopeConsole.info, error: scopeConsole.error, - warn: scopeConsole.warn + warn: scopeConsole.warn, + debug: scopeConsole.debug, + dir: scopeConsole.dir, + trace: scopeConsole.trace, + table: scopeConsole.table }; const toText = (args: any[]) => { @@ -172,6 +176,13 @@ export class JavaScriptRuntimeEvaluator { } try { + if (typeof arg === 'object' && arg !== null) { + const bundle = this._executor.getMimeBundle(arg); + const plain = bundle['text/plain']; + if (typeof plain === 'string') { + return plain; + } + } return String(arg); } catch { return '[Unprintable value]'; @@ -200,6 +211,11 @@ export class JavaScriptRuntimeEvaluator { scopeConsole.warn = scopeConsole.error; + scopeConsole.debug = scopeConsole.log; + scopeConsole.dir = scopeConsole.log; + scopeConsole.trace = scopeConsole.log; + scopeConsole.table = scopeConsole.log; + if ('onerror' in this._globalScope) { this._originalOnError = this._globalScope.onerror; this._globalScope.onerror = (message: any) => { @@ -223,6 +239,10 @@ export class JavaScriptRuntimeEvaluator { scopeConsole.info = this._originalConsole.info; scopeConsole.error = this._originalConsole.error; scopeConsole.warn = this._originalConsole.warn; + scopeConsole.debug = this._originalConsole.debug; + scopeConsole.dir = this._originalConsole.dir; + scopeConsole.trace = this._originalConsole.trace; + scopeConsole.table = this._originalConsole.table; } if ('onerror' in this._globalScope) { @@ -275,6 +295,10 @@ export class JavaScriptRuntimeEvaluator { info: Console['info']; error: Console['error']; warn: Console['warn']; + debug: Console['debug']; + dir: Console['dir']; + trace: Console['trace']; + table: Console['table']; } | null = null; }