// wasm/python/lumbda-py.js // Python tier loader — Pyodide (CPython-in-WASM) hosting lumbda.py. // // ES module form so it works in both window and Web Worker contexts. // Exports createPythonTier() -> Promise<{ evalLisp(src) -> Promise, // setBendUrl(url) -> void, // heapStats() -> {used,total} }>. const PYODIDE_VERSION = "0.27.2"; const PYODIDE_INDEX_URL = `https://cdn.jsdelivr.net/pyodide/v${PYODIDE_VERSION}/full/`; async function _bootstrap() { // Dynamic ES-module import works in both window and Worker (module type) // contexts. The CDN ships pyodide.mjs alongside pyodide.js. const { loadPyodide } = await import(PYODIDE_INDEX_URL + "pyodide.mjs"); const pyodide = await loadPyodide({ indexURL: PYODIDE_INDEX_URL }); // Pull lumbda.py + stdlib.lsp into Pyodide's virtual FS. Paths resolve // relative to THIS loader (under python/) for both window and Worker. const pyURL = new URL("./lumbda.py", import.meta.url).href; const stdlibURL = new URL("./stdlib.lsp", import.meta.url).href; const lumbdaSrc = await (await fetch(pyURL)).text(); const stdlibSrc = await (await fetch(stdlibURL)).text(); pyodide.FS.writeFile("/home/pyodide/lumbda.py", lumbdaSrc); pyodide.FS.writeFile("/home/pyodide/stdlib.lsp", stdlibSrc); // bend dispatch closure. Browser can't open raw TCP, so we POST // the S-expression payload to the configured worker URL and read // the response text back. Sync XHR is the only sync HTTP available // in a Web Worker; perfect for the blocking eval model lumbda // primitives expect. const refs = { bendUrl: null, currentOnChunk: null }; // Streaming output bridge — sys.stdout in pyodide is replaced // (during _lumbda_eval) with a class whose write() calls back // here. We forward to the current onChunk so the worker can // postMessage chunks to the UI as work happens, instead of the // user staring at a blank panel during a long bend dispatch. globalThis._lumbdaPyEmitChunk = (s) => { if (refs.currentOnChunk && s) refs.currentOnChunk(s); }; globalThis._lumbdaPyBendCall = (payload) => { if (!refs.bendUrl) return "no bend URL configured"; try { const xhr = new XMLHttpRequest(); xhr.open("POST", refs.bendUrl, false); // sync xhr.setRequestHeader("Content-Type", "text/plain; charset=utf-8"); xhr.send(payload); return xhr.responseText || ""; } catch (e) { return `bend error: ${e.message || String(e)}`; } }; await pyodide.runPythonAsync(` import sys, io sys.path.insert(0, "/home/pyodide") import lumbda _env = lumbda.make_global_env() for _e in lumbda.read_all(lumbda.PRELUDE): lumbda.leval(_e, _env) # bend!-call primitive — bridges to JS XHR via globalThis._lumbdaPyBendCall. # Argument: a string payload (the S-expression text). Returns response text. from js import _lumbdaPyBendCall as _js_bend_call def _bend_call_prim(args, env): if not args: return "" payload = args[0] if not isinstance(payload, str): payload = lumbda.show(payload) return str(_js_bend_call(payload)) _env.define(lumbda.S('bend!-call'), _bend_call_prim) # Portal save/resume — REPL bridge. Mirrors the C tier's # (portal-snapshot! NAME) / (portal-load! NAME): writes/reads # /tmp/.portal so the JS side can round-trip blobs to vault. import os os.makedirs('/tmp', exist_ok=True) def _portal_snapshot_prim(args, env): if not args or not isinstance(args[0], str): raise lumbda.LispErr('portal-snapshot!: expected string name') path = f'/tmp/{args[0]}.portal' g = env.g if env.g else _env lumbda.portal_save(g, path) return path def _portal_load_prim(args, env): if not args or not isinstance(args[0], str): raise lumbda.LispErr('portal-load!: expected string name') path = f'/tmp/{args[0]}.portal' restored_env, _cont = lumbda.portal_resume(path, _env) # Copy bindings from the restored env into our live global env so # subsequent evals see them. portal_resume gives us back a fresh # env with the loaded bindings; we merge into _env in-place. for sym, val in restored_env.b.items(): _env.b[sym] = val return True _env.define(lumbda.S('portal-snapshot!'), _portal_snapshot_prim) _env.define(lumbda.S('portal-load!'), _portal_load_prim) # Streaming stdout wrapper — every write() also calls back into JS so # the worker can postMessage chunks to the UI during the eval. The # StringIO behind it still captures everything for the final return. from js import _lumbdaPyEmitChunk as _js_emit_chunk class _StreamingStdout(io.StringIO): def write(self, s): n = super().write(s) try: _js_emit_chunk(s) except Exception: pass return n def _lumbda_eval(src): buf = _StreamingStdout() old = sys.stdout sys.stdout = buf last = None try: for e in lumbda.read_all(src): last = lumbda.leval(e, _env) except Exception as ex: sys.stdout = old return f"{buf.getvalue()}error: {ex}" sys.stdout = old out = buf.getvalue() if last is not None and not isinstance(last, lumbda._Void): rep = lumbda.show(last) if out and not out.endswith("\\n"): out += "\\n" out += rep try: _js_emit_chunk(("" if out.endswith(rep) else "\\n") + rep) except Exception: pass return out `); return { async evalLisp(src, onChunk) { pyodide.globals.set("_src_in", src); refs.currentOnChunk = onChunk || null; try { return await pyodide.runPythonAsync("_lumbda_eval(_src_in)"); } finally { refs.currentOnChunk = null; } }, setBendUrl(url) { refs.bendUrl = url || null; }, // Portal save/resume bridge to vault — same shape as the C tier // loader. Pyodide.FS is the same Emscripten FS, so the path // /tmp/.portal is reachable from JS exactly as in C. portalSave(name) { const path = `/tmp/${name}.portal`; try { const bytes = pyodide.FS.readFile(path); return new TextDecoder().decode(bytes); } catch (e) { return null; } }, portalLoad(name, blob) { const path = `/tmp/${name}.portal`; try { pyodide.FS.mkdir("/tmp"); } catch (e) { /* exists */ } pyodide.FS.writeFile(path, blob); }, heapStats() { // Pyodide's runtime memory is the Emscripten linear memory. // CPython's GC reclaims behind the scenes, so this number // rises and falls naturally as objects die. const total = pyodide._module.HEAPU8.byteLength; return { used: total, total }; }, }; } // Closure-encapsulated singleton (see lumbda-c.loader.js for rationale). export const createPythonTier = (() => { let tier = null; return async () => { if (!tier) tier = await _bootstrap(); return tier; }; })();