From 209f5cae9c6bd2cb66cc9e3135e1a810cb2de751 Mon Sep 17 00:00:00 2001 From: Stewart Allen Date: Wed, 24 Dec 2025 14:15:47 -0500 Subject: [PATCH] add quickjs eval engine for macros --- bin/webpack-quickjs-bundle.js | 28 ++++ bin/webpack-quickjs-esm.js | 50 ++++++ package.json | 4 +- src/kiri/core/print.js | 23 ++- src/kiri/mode/cam/prepare.js | 3 + src/kiri/mode/fdm/prepare.js | 3 + src/kiri/run/worker.js | 19 ++- src/moto/quickjs.js | 280 ++++++++++++++++++++++++++++++++++ web/kiri/index.html | 4 +- 9 files changed, 404 insertions(+), 10 deletions(-) create mode 100644 bin/webpack-quickjs-bundle.js create mode 100644 bin/webpack-quickjs-esm.js create mode 100644 src/moto/quickjs.js diff --git a/bin/webpack-quickjs-bundle.js b/bin/webpack-quickjs-bundle.js new file mode 100644 index 00000000..7ec6d58b --- /dev/null +++ b/bin/webpack-quickjs-bundle.js @@ -0,0 +1,28 @@ +import * as QuickJSModule from "../node_modules/quickjs-emscripten/dist/index.js"; + +// Re-export all the named exports +export const getQuickJS = QuickJSModule.getQuickJS; +export const getQuickJSSync = QuickJSModule.getQuickJSSync; +export const newQuickJSWASMModule = QuickJSModule.newQuickJSWASMModule; +export const newQuickJSAsyncWASMModule = QuickJSModule.newQuickJSAsyncWASMModule; +export const newAsyncRuntime = QuickJSModule.newAsyncRuntime; +export const newAsyncContext = QuickJSModule.newAsyncContext; +export const DEBUG_SYNC = QuickJSModule.DEBUG_SYNC; +export const DEBUG_ASYNC = QuickJSModule.DEBUG_ASYNC; +export const RELEASE_SYNC = QuickJSModule.RELEASE_SYNC; +export const RELEASE_ASYNC = QuickJSModule.RELEASE_ASYNC; +export const errors = QuickJSModule.errors; +export const memoizePromiseFactory = QuickJSModule.memoizePromiseFactory; +export const Lifetime = QuickJSModule.Lifetime; +export const Scope = QuickJSModule.Scope; +export const WeakLifetime = QuickJSModule.WeakLifetime; +export const StaticLifetime = QuickJSModule.StaticLifetime; +export const TestQuickJSWASMModule = QuickJSModule.TestQuickJSWASMModule; +export const isFail = QuickJSModule.isFail; +export const isSuccess = QuickJSModule.isSuccess; +export const assertSync = QuickJSModule.assertSync; +export const DeferredPromise = QuickJSModule.DeferredPromise; +export const shouldInterruptAfterDeadline = QuickJSModule.shouldInterruptAfterDeadline; + +// Default export +export default QuickJSModule; diff --git a/bin/webpack-quickjs-esm.js b/bin/webpack-quickjs-esm.js new file mode 100644 index 00000000..4012a5ad --- /dev/null +++ b/bin/webpack-quickjs-esm.js @@ -0,0 +1,50 @@ +const path = require('path'); +const webpack = require('webpack'); + +module.exports = { + mode: 'production', + entry: path.resolve(__dirname, './webpack-quickjs-bundle.js'), + output: { + path: path.resolve(__dirname, '../src/ext'), + filename: 'quickjs.js', + library: { + type: 'module' + }, + module: true + }, + experiments: { + outputModule: true + }, + resolve: { + extensions: ['.mjs', '.js'], + fallback: { + 'path': false, + 'fs': false, + 'crypto': false + } + }, + plugins: [ + new webpack.DefinePlugin({ + 'process.env.QTS_DEBUG': JSON.stringify(false), + 'process.env.NODE_ENV': JSON.stringify('production') + }) + ], + module: { + rules: [ + { + test: /\.m?js$/, + resolve: { + fullySpecified: false, + }, + }, + ], + }, + optimization: { + minimize: false + }, + performance: { + hints: false, + maxAssetSize: 2 * 1024 * 1024, + maxEntrypointSize: 2 * 1024 * 1024, + }, +}; diff --git a/package.json b/package.json index facdcf06..6dced782 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "mqtt": "^5.10.3", "npm": "^11.4.2", "prettier": "^3.5.3", + "quickjs-emscripten": "^0.20.0", "react-responsive-carousel": "^3.2.23", "serve-static": "^2.2.0", "three": "^0.182.0", @@ -120,7 +121,8 @@ "start-ddb": "npm run prebuild && electron . --devel --debugg", "start-dev": "npm run prebuild prod && electron . --devel", "start": "npm run prebuild && electron .", - "webpack-ext": "npm run webpack-three && npm run webpack-zip", + "webpack-ext": "npm run webpack-three && npm run webpack-zip && npm run webpack-qjs", + "webpack-qjs": "npx webpack --config bin/webpack-quickjs-esm.js", "webpack-src": "node bin/esbuild.config.mjs", "webpack-three": "npx webpack --config bin/webpack-three-esm.js", "webpack-zip": "npx webpack --config bin/webpack-jszip-esm.js" diff --git a/src/kiri/core/print.js b/src/kiri/core/print.js index 11419606..a090a0c6 100644 --- a/src/kiri/core/print.js +++ b/src/kiri/core/print.js @@ -2,6 +2,7 @@ import { arcToPath } from '../../geo/paths.js'; import { consts } from './consts.js'; +import { createVM } from '../../moto/quickjs.js'; import { newPoint } from '../../geo/point.js'; import { util } from '../../geo/base.js'; @@ -24,6 +25,7 @@ class Print { this.tools = {}; // set to 1 to enable flow rate analysis (console) this.debugE = settings ? (settings.controller.devel ? 1 : 0) : 0; + this._ready = this.createSafeEval(); } setType(type) { @@ -237,7 +239,26 @@ class Print { return output.last().point; } + async ready() { + await this._ready; + } + + async createSafeEval() { + this.safeEval = await createVM(); + this.safeEval.eval("function range(a,b) { return (a + (layer / layers) * (b-a)) }"); + } + + disposeSafeEval() { + if (this.safeEval) { + this.safeEval.dispose(); + } + } + constReplace(str, consts, start, pad, short) { + let safeEval = this.safeEval; + if (safeEval) { + safeEval.setContext(consts); + } function tryeval(str) { try { return eval(`{ ${str} }`) @@ -269,7 +290,7 @@ class Print { } eva.push(`function range(a,b) { return (a + (layer / layers) * (b-a)).round(4) }`); eva.push(`try {( ${tok} )} catch (e) {console.log(e);0}`); - let evl = tryeval(eva.join('')); + let evl = safeEval ? safeEval.eval(tok) : tryeval(eva.join('')); nutok = evl; if (pad === 666) { return evl; diff --git a/src/kiri/mode/cam/prepare.js b/src/kiri/mode/cam/prepare.js index d77245f4..7c63c418 100644 --- a/src/kiri/mode/cam/prepare.js +++ b/src/kiri/mode/cam/prepare.js @@ -30,6 +30,9 @@ export async function cam_prepare(widgets, settings, update) { const print = self.kiri_worker.current.print = newPrint(settings, active); const { origin } = settings; + // wait for safe eval setup + await print.ready(); + // cam-specific storage print.output = []; diff --git a/src/kiri/mode/fdm/prepare.js b/src/kiri/mode/fdm/prepare.js index 8fd835b3..4dfe09fa 100644 --- a/src/kiri/mode/fdm/prepare.js +++ b/src/kiri/mode/fdm/prepare.js @@ -47,6 +47,9 @@ export async function fdm_prepare(widgets, settings, update) { output = [], layerout = []; + // wait for safe eval setup + await print.ready(); + // compute bounds if missing if (!bounds) { bounds = new THREE.Box3(); diff --git a/src/kiri/run/worker.js b/src/kiri/run/worker.js index a63fbeeb..82f422fc 100644 --- a/src/kiri/run/worker.js +++ b/src/kiri/run/worker.js @@ -58,6 +58,13 @@ function debug() { console.log(...arguments); } +function setPrint(print) { + if (current.print && current.print !== print) { + current.print.disposeSafeEval(); + } + return current.print = print; +} + // catch clipper alerts and convert to console messages self.alert = function(o) { console.log(o); @@ -66,7 +73,7 @@ self.alert = function(o) { self.uuid = ((Math.random() * Date.now()) | 0).toString(36); /** - * @returns {RasterPath} + * @returns {RasterPath} instantiated class */ self.get_raster_gpu = async function({ mode, resolution, rotationStep }) { let gpu = new RasterPath({ @@ -314,7 +321,7 @@ const dispatch = { // purge all sync data clear(data, send) { // current.snap = null; - current.print = null; + setPrint(null); dispatch.group = wgroup = {}; dispatch.cache = worker.cache = wcache = {}; Widget.Groups.clear(); @@ -427,7 +434,7 @@ const dispatch = { let last = time(), now; - current.print = null; + setPrint(null); current.mode = settings.mode.toUpperCase(); widget.anno = data.anno || widget.anno; @@ -496,7 +503,7 @@ const dispatch = { send.data(emit, state.zeros); }).then(() => { const unitScale = settings.controller.units === 'in' ? (1 / 25.4) : 1; - const print = current.print || {}; + const print = setPrint(current.print || {}); const minSpeed = (print.minSpeed || 0) * unitScale; const maxSpeed = (print.maxSpeed || 0) * unitScale; @@ -571,7 +578,7 @@ const dispatch = { z: origin.z - (process.camOriginOffZ ?? 0) }; const device = settings.device; - const print = current.print = newPrint(settings, Object.values(wcache)); + const print = setPrint(newPrint(settings, Object.values(wcache))); const tools = device.extruders; const mode = settings.mode; const thin = settings.controller.lineType === 'line' || mode !== 'FDM'; @@ -601,7 +608,7 @@ const dispatch = { out.point = newPoint(x,y,z || 0); }); }); - const print = current.print = newPrint(null, Object.values(wcache)); + const print = setPrint(newPrint(null, Object.values(wcache))); render.path(parsed, progress => { send.data({ progress }); }, { thin: true }) diff --git a/src/moto/quickjs.js b/src/moto/quickjs.js new file mode 100644 index 00000000..002effd3 --- /dev/null +++ b/src/moto/quickjs.js @@ -0,0 +1,280 @@ +/** + * QuickJS VM Wrapper + * Provides a simplified interface for working with QuickJS contexts + */ + +import { getQuickJS } from '../ext/quickjs.js'; + +let quickJSInstance = null; + +/** + * Get or initialize the QuickJS singleton instance + */ +async function getQuickJSInstance() { + if (!quickJSInstance) { + quickJSInstance = await getQuickJS(); + } + return quickJSInstance; +} + +/** + * QuickJS VM wrapper with helper methods + * Automatically manages a QuickJS context lifecycle + */ +class QuickJSVM { + /** + * Create a new QuickJS VM context + * @param {Object} options - Optional VM configuration + */ + constructor(options = {}) { + this.vm = null; + this.disposed = false; + this.initPromise = this._init(options); + } + + /** + * Internal initialization (async) + */ + async _init(options) { + const QuickJS = await getQuickJSInstance(); + this.vm = QuickJS.newContext(options); + } + + /** + * Ensure VM is initialized before use + */ + async ready() { + await this.initPromise; + if (this.disposed) { + throw new Error('VM has been disposed'); + } + return this; + } + + /** + * Set multiple variables in the global context + * @param {Object} obj - Object with key/value pairs to set as globals + * @example + * vm.setContext({ + * x: 123, + * config: { width: 800, height: 600 }, + * items: [1, 2, 3] + * }); + */ + setContext(obj) { + for (const [key, value] of Object.entries(obj)) { + const handle = this.jsToVm(value); + this.vm.setProp(this.vm.global, key, handle); + handle.dispose(); + } + return this; + } + + /** + * Set a single global variable + * @param {string} name - Variable name + * @param {*} value - JavaScript value to set + * @example + * vm.set('x', 123); + * vm.set('config', { width: 800 }); + */ + set(name, value) { + const handle = this.jsToVm(value); + this.vm.setProp(this.vm.global, name, handle); + handle.dispose(); + return this; + } + + /** + * Get a global variable value + * @param {string} name - Variable name + * @returns {*} JavaScript value + * @example + * const x = vm.get('x'); // 123 + */ + get(name) { + const handle = this.vm.getProp(this.vm.global, name); + const value = this.vm.dump(handle); + handle.dispose(); + return value; + } + + /** + * Convert JavaScript value to QuickJS handle + * @param {*} value - JavaScript value + * @returns {QuickJSHandle} QuickJS handle (caller must dispose) + */ + jsToVm(value) { + // Handle primitives + if (value === null) { + return this.vm.null; + } + + if (value === undefined) { + return this.vm.undefined; + } + + if (typeof value === 'number') { + return this.vm.newNumber(value); + } + + if (typeof value === 'string') { + return this.vm.newString(value); + } + + if (typeof value === 'boolean') { + return value ? this.vm.true : this.vm.false; + } + + // Handle arrays + if (Array.isArray(value)) { + const arr = this.vm.newArray(); + for (let i = 0; i < value.length; i++) { + const itemHandle = this.jsToVm(value[i]); + this.vm.setProp(arr, i, itemHandle); + itemHandle.dispose(); + } + return arr; + } + + // Handle objects + if (typeof value === 'object') { + const obj = this.vm.newObject(); + for (const [k, v] of Object.entries(value)) { + const propHandle = this.jsToVm(v); + this.vm.setProp(obj, k, propHandle); + propHandle.dispose(); + } + return obj; + } + + // Fallback for unsupported types + console.warn(`Unsupported type for VM: ${typeof value}, setting to undefined`); + return this.vm.undefined; + } + + /** + * Evaluate JavaScript code in the VM + * @param {string} code - JavaScript code to evaluate + * @returns {*} Result value or throws error + * @example + * const result = vm.eval('1 + 2'); // 3 + */ + eval(code) { + const result = this.vm.evalCode(code); + + if (result.error) { + const error = this.vm.dump(result.error); + result.error.dispose(); + throw new Error(`VM Error: ${error}`); + } + + const value = this.vm.dump(result.value); + result.value.dispose(); + return value; + } + + /** + * Evaluate code and return raw result handle (caller must dispose) + * @param {string} code - JavaScript code to evaluate + * @returns {Object} { error?: handle, value?: handle } + */ + evalRaw(code) { + return this.vm.evalCode(code); + } + + /** + * Create a JavaScript function that can be called from VM code + * @param {string} name - Function name in VM + * @param {Function} fn - JavaScript function to wrap + * @example + * vm.setFunction('add', (a, b) => a + b); + * vm.eval('add(2, 3)'); // 5 + */ + setFunction(name, fn) { + const fnHandle = this.vm.newFunction(name, (...args) => { + // Convert VM args to JS + const jsArgs = args.map(arg => this.vm.dump(arg)); + + // Call JS function + const result = fn(...jsArgs); + + // Convert result back to VM + return this.jsToVm(result); + }); + + this.vm.setProp(this.vm.global, name, fnHandle); + fnHandle.dispose(); + return this; + } + + /** + * Create an object with JSON-like structure + * @param {Object} data - JavaScript object to convert + * @returns {QuickJSHandle} Handle to VM object (caller must dispose) + */ + newJSON(data) { + return this.vm.unwrapResult( + this.vm.evalCode(`(${JSON.stringify(data)})`) + ); + } + + /** + * Execute a function in the VM with arguments + * @param {string} funcName - Function name in VM + * @param {...*} args - Arguments to pass + * @returns {*} Result value + * @example + * vm.eval('function add(a, b) { return a + b; }'); + * vm.call('add', 2, 3); // 5 + */ + call(funcName, ...args) { + const argsStr = args.map(a => JSON.stringify(a)).join(', '); + return this.eval(`${funcName}(${argsStr})`); + } + + /** + * Check if VM has been disposed + */ + isDisposed() { + return this.disposed; + } + + /** + * Get direct access to underlying QuickJS context + * Use with caution - you're responsible for handle management + */ + getContext() { + return this.vm; + } + + /** + * Dispose the VM and free all resources + * Must be called when done to prevent memory leaks + */ + dispose() { + if (!this.disposed && this.vm) { + this.vm.dispose(); + this.disposed = true; + this.vm = null; + } + } +} + +/** + * Create and initialize a new QuickJS VM + * @param {Object} options - Optional VM configuration + * @returns {Promise} Initialized VM instance + * @example + * const vm = await createVM(); + * vm.set('x', 123); + * const result = vm.eval('x * 2'); // 246 + * vm.dispose(); + */ +async function createVM(options = {}) { + const vm = new QuickJSVM(options); + await vm.ready(); + return vm; +} + +export { QuickJSVM, createVM }; diff --git a/web/kiri/index.html b/web/kiri/index.html index b7003067..ae9e1a88 100644 --- a/web/kiri/index.html +++ b/web/kiri/index.html @@ -22,8 +22,8 @@ - + +