lumbda/wasm/app/app.js
russell@unturf.com 7e32eefd6b
playground+repl: bend dispatch from WASM + 1.33 zoom + free-form runner
bend!-call from the WAT tier
  - New WAT import: (import "env" "bend_call"). The host loader supplies
    a sync XMLHttpRequest that POSTs the payload to a configured URL
    (workers only — sync XHR isn't allowed on main thread).
  - New primitive (bend!-call "<payload>") returns the response as a
    lumbda string. Works in playground and REPL once the bend URL is
    saved in the new top bar.
  - bendUrl persists in plain localStorage (not encrypted — it's a
    server address, not a secret).
  - Tests stub the import with a no-op so unit / integration / functional
    suites keep instantiating cleanly.

Playground + REPL zoom 133% by default
  - html { zoom: 1.33 } so the styleguide sizes read comfortably without
    requiring browser-level zoom.

Free-form default = cross-tier assertion runner in Lisp
  - The default editor content for "free form" is now a small assertion
    framework matching tests/functional.lsp's PASS/FAIL convention. A
    starter the user can extend, runs identically on the three tiers.

C tier + Python tier (bend) are wired through the runner stub; full
bend integration in those tiers comes next once their loaders learn
about setBendUrl.
2026-06-14 13:05:06 -04:00

381 lines
14 KiB
JavaScript

// wasm/app/app.js
// Single-page app shell — CodeMirror 6 editor + Web-Worker-backed tier
// runner. The main thread stays responsive: the live ms counter ticks
// every animation frame, and Cancel terminates the worker mid-eval.
import { EditorState } from "@codemirror/state";
import { EditorView, keymap, lineNumbers, drawSelection } from "@codemirror/view";
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import { syntaxHighlighting, defaultHighlightStyle, StreamLanguage } from "@codemirror/language";
import { scheme } from "@codemirror/legacy-modes/mode/scheme";
import { oneDark } from "@codemirror/theme-one-dark";
import { openVault } from "./crypto.js";
const TIERS = { python: "python (pyodide)", c: "c (emcc)", asm: "asm (wat)" };
const FREE_FORM_DEFAULT = `; free-form mode — write any lisp, race all three tiers.
; unlock the vault below to persist this code (encrypted localStorage).
;
; below: a tiny version of the cross-tier functional.lsp runner.
(define pass 0)
(define fail 0)
(define (check name got expected)
(cond ((equal? got expected)
(set! pass (+ pass 1))
(display "PASS: ") (display name) (newline))
(else
(set! fail (+ fail 1))
(display "FAIL: ") (display name)
(display " got=") (display got)
(display " expected=") (display expected) (newline))))
(check "arithmetic" (+ 1 2) 3)
(check "division" (/ 42 6) 7)
(check "string" (string-append "foo" "bar") "foobar")
(check "list reverse" (reverse (list 1 2 3)) (list 3 2 1))
(check "vector len" (vector-length (vector 1 2 3)) 3)
(check "char" (char->integer #\\A) 65)
(check "let loop" (let loop ((i 0) (n 0))
(if (= i 10) n (loop (+ i 1) (+ n i)))) 45)
(display pass) (display " pass, ") (display fail) (display " fail")
(newline)
`;
const demoSources = {};
// Vault state. When unlocked, free-form code auto-saves on every edit.
const vaultState = { vault: null, freeForm: null, saveTimer: null };
async function loadDemoSource(name) {
if (name === "free-form") {
return vaultState.freeForm != null ? vaultState.freeForm : FREE_FORM_DEFAULT;
}
if (!demoSources[name]) {
const resp = await fetch(`demos/${name}.lsp`);
demoSources[name] = await resp.text();
}
return demoSources[name];
}
const editorParent = document.getElementById("editor");
const outputEl = document.getElementById("output");
const statusEl = document.getElementById("status");
const runBtn = document.getElementById("run");
const cancelBtn = document.getElementById("cancel");
const editorView = new EditorView({
state: EditorState.create({
doc: "",
extensions: [
lineNumbers(),
history(),
drawSelection(),
syntaxHighlighting(defaultHighlightStyle),
StreamLanguage.define(scheme),
keymap.of([...defaultKeymap, ...historyKeymap]),
oneDark,
EditorView.updateListener.of((u) => {
if (u.docChanged) scheduleFreeFormSave();
}),
],
}),
parent: editorParent,
});
function setEditorText(text) {
editorView.dispatch({
changes: { from: 0, to: editorView.state.doc.length, insert: text },
});
}
function getEditorText() {
return editorView.state.doc.toString();
}
async function loadCurrentDemo() {
const sel = document.querySelector('input[name="program"]:checked').value;
const vaultBar = document.getElementById("vault-bar");
vaultBar.hidden = sel !== "free-form";
const src = await loadDemoSource(sel);
setEditorText(src);
}
// ─── Free-form vault ───────────────────────────────────────────────
const vaultBarEl = document.getElementById("vault-bar");
const vaultPwEl = document.getElementById("vault-pw");
const vaultUnlockBtn = document.getElementById("vault-unlock");
const vaultLockBtn = document.getElementById("vault-lock");
const vaultStateEl = document.getElementById("vault-state");
const vaultNoteEl = document.getElementById("vault-note");
function setVaultNote(text, isErr) {
vaultNoteEl.textContent = text || "";
vaultNoteEl.className = "vault-note" + (isErr ? " err" : "");
}
async function unlockVault() {
const pw = vaultPwEl.value;
if (!pw) { setVaultNote("password required", true); return; }
setVaultNote("");
try {
vaultState.vault = await openVault(pw);
const data = await vaultState.vault.read();
if (data && data.__decryptionFailed) {
setVaultNote("vault exists but password is wrong", true);
vaultState.vault = null;
return;
}
vaultState.freeForm = (data && typeof data.freeForm === "string")
? data.freeForm
: FREE_FORM_DEFAULT;
vaultStateEl.textContent = "unlocked";
vaultStateEl.classList.add("unlocked");
vaultUnlockBtn.hidden = true;
vaultLockBtn.hidden = false;
vaultPwEl.value = "";
vaultPwEl.disabled = true;
// If free-form is the current program, swap the editor in.
const sel = document.querySelector('input[name="program"]:checked').value;
if (sel === "free-form") setEditorText(vaultState.freeForm);
setVaultNote("ok — edits auto-save");
} catch (e) {
setVaultNote("unlock failed: " + e.message, true);
}
}
function lockVault() {
vaultState.vault = null;
vaultState.freeForm = null;
if (vaultState.saveTimer) { clearTimeout(vaultState.saveTimer); vaultState.saveTimer = null; }
vaultStateEl.textContent = "locked";
vaultStateEl.classList.remove("unlocked");
vaultUnlockBtn.hidden = false;
vaultLockBtn.hidden = true;
vaultPwEl.disabled = false;
vaultPwEl.value = "";
setVaultNote("");
// If free-form is current program, swap to the placeholder.
const sel = document.querySelector('input[name="program"]:checked').value;
if (sel === "free-form") setEditorText(FREE_FORM_DEFAULT);
}
function scheduleFreeFormSave() {
if (!vaultState.vault) return;
const sel = document.querySelector('input[name="program"]:checked').value;
if (sel !== "free-form") return;
if (vaultState.saveTimer) clearTimeout(vaultState.saveTimer);
vaultState.saveTimer = setTimeout(async () => {
const text = getEditorText();
vaultState.freeForm = text;
try { await vaultState.vault.write({ freeForm: text, savedAt: Date.now() }); }
catch (e) { setVaultNote("save failed: " + e.message, true); }
}, 350);
}
function selectedTiers() {
const sel = document.querySelector('input[name="tier"]:checked').value;
return sel === "all" ? ["python", "c", "asm"] : [sel];
}
function setStatus(text, cls) {
statusEl.textContent = text || "";
statusEl.className = "status" + (cls ? " " + cls : "");
}
// ─── Worker plumbing ───────────────────────────────────────────────
// ONE worker per tier so the three tiers run on independent threads —
// in "All three" mode they race, and a slow tier never blocks a fast one.
// Cancel terminates every active worker; next eval recreates them.
const workerState = {
workers: { python: null, c: null, asm: null },
pending: { python: null, c: null, asm: null },
nextRunId: 0,
};
function spawnWorker() {
const w = new Worker(new URL("./worker.mjs", import.meta.url), { type: "module" });
if (bendState.url) w.postMessage({ kind: "config", bendUrl: bendState.url });
return w;
}
function ensureWorker(tier) {
if (!workerState.workers[tier]) workerState.workers[tier] = spawnWorker();
return workerState.workers[tier];
}
// ─── Bend URL ──────────────────────────────────────────────────────
const bendState = { url: localStorage.getItem("lumbda_bend_url") || "" };
const bendUrlEl = document.getElementById("bend-url");
const bendSaveBtn = document.getElementById("bend-save");
const bendStateEl = document.getElementById("bend-state");
if (bendState.url) {
bendUrlEl.value = bendState.url;
bendStateEl.textContent = "saved";
}
function saveBendUrl() {
bendState.url = bendUrlEl.value.trim();
if (bendState.url) {
localStorage.setItem("lumbda_bend_url", bendState.url);
bendStateEl.textContent = "saved";
} else {
localStorage.removeItem("lumbda_bend_url");
bendStateEl.textContent = "";
}
// Push config to any already-spawned workers.
for (const w of Object.values(workerState.workers)) {
if (w) w.postMessage({ kind: "config", bendUrl: bendState.url });
}
}
bendSaveBtn.addEventListener("click", saveBendUrl);
bendUrlEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); saveBendUrl(); }
});
function runOnTierInWorker(tier, src, onLoading) {
return new Promise((resolve, reject) => {
const w = ensureWorker(tier);
const myRunId = ++workerState.nextRunId;
workerState.pending[tier] = { runId: myRunId, resolve, reject };
const handler = (e) => {
if (e.data.runId !== myRunId) return;
if (e.data.kind === "loading") {
onLoading && onLoading(e.data.tier);
} else if (e.data.kind === "done") {
w.removeEventListener("message", handler);
workerState.pending[tier] = null;
resolve(e.data.output);
} else if (e.data.kind === "error") {
w.removeEventListener("message", handler);
workerState.pending[tier] = null;
reject(new Error(e.data.message));
}
};
w.addEventListener("message", handler);
w.postMessage({ kind: "eval", runId: myRunId, tier, src });
});
}
function cancelCurrentRun() {
for (const t of Object.keys(workerState.workers)) {
if (workerState.workers[t]) {
workerState.workers[t].terminate();
workerState.workers[t] = null;
}
if (workerState.pending[t]) {
workerState.pending[t].reject(new Error("cancelled"));
workerState.pending[t] = null;
}
}
}
// Append a finished tier-block to the output. Order = finish order, so
// the fastest tier appears first naturally.
function appendBlock(tierName, elapsedMs, text, kind) {
const block = document.createElement("div");
block.className = "tier-block";
const h = document.createElement("h3");
h.textContent = TIERS[tierName] || tierName;
const t = document.createElement("span");
t.className = "time";
if (kind === "cancelled") t.textContent = ` (cancelled @ ${elapsedMs.toFixed(0)} ms)`;
else t.textContent = ` (${elapsedMs.toFixed(0)} ms)`;
h.appendChild(t);
block.appendChild(h);
const pre = document.createElement("pre");
if (kind === "error") pre.className = "err";
pre.textContent = text;
block.appendChild(pre);
outputEl.appendChild(block);
}
// ─── Run / cancel ──────────────────────────────────────────────────
let inFlight = false;
async function runAll() {
if (inFlight) return;
inFlight = true;
runBtn.disabled = true;
cancelBtn.disabled = false;
setStatus("running…", "busy");
outputEl.innerHTML = "";
const tiers = selectedTiers();
const src = getEditorText();
// Each tier in its own worker. Append-only output: as each tier finishes,
// we append its block — so the fastest tier shows up first.
const startTimes = {};
const tickStatus = () => {
const parts = [];
for (const t of tiers) {
if (startTimes[t] !== undefined) {
parts.push(`${t} ${((performance.now() - startTimes[t]) | 0)}ms`);
}
}
setStatus(parts.join(" · "), "busy");
};
let raf = requestAnimationFrame(function loop() {
tickStatus();
raf = requestAnimationFrame(loop);
});
const tierPromises = tiers.map((t) => {
startTimes[t] = performance.now();
return runOnTierInWorker(t, src, (loadingTier) => {
setStatus(`loading ${loadingTier}`, "busy");
})
.then((output) => {
const elapsed = performance.now() - startTimes[t];
delete startTimes[t];
appendBlock(t, elapsed, output, "ok");
return { tier: t, ok: true, elapsed };
})
.catch((e) => {
const elapsed = performance.now() - startTimes[t];
delete startTimes[t];
if (e.message === "cancelled") {
appendBlock(t, elapsed, "(cancelled)", "cancelled");
return { tier: t, cancelled: true, elapsed };
}
appendBlock(t, elapsed, e.message || String(e), "error");
return { tier: t, error: e.message, elapsed };
});
});
try {
const results = await Promise.all(tierPromises);
cancelAnimationFrame(raf);
const cancelled = results.some((r) => r.cancelled);
const anyErr = results.some((r) => r.error);
if (cancelled) setStatus("cancelled", "warn");
else if (anyErr) setStatus("completed with errors", "err");
else {
const fastest = results.reduce((a, b) => (a.elapsed < b.elapsed ? a : b));
setStatus(`ok — ${fastest.tier} won in ${fastest.elapsed.toFixed(0)} ms`, "ok");
}
} catch (e) {
cancelAnimationFrame(raf);
setStatus(`fatal: ${e.message}`, "err");
} finally {
inFlight = false;
runBtn.disabled = false;
cancelBtn.disabled = true;
}
}
function onCancel() {
if (!inFlight) return;
setStatus("cancelling…", "warn");
cancelCurrentRun();
}
document.querySelectorAll('input[name="program"]').forEach((el) => {
el.addEventListener("change", loadCurrentDemo);
});
runBtn.addEventListener("click", runAll);
cancelBtn.addEventListener("click", onCancel);
cancelBtn.disabled = true;
vaultUnlockBtn.addEventListener("click", unlockVault);
vaultLockBtn.addEventListener("click", lockVault);
vaultPwEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); unlockVault(); }
});
loadCurrentDemo();