Apply the REPL's per-line-div → single Text node refactor to the playground too. Same perf win on high-volume streams plus a bonus: the 5 functional tests that started failing after the per-line-div era (mandelbrot/fib-ack/sieve/self-interp on C, fib-ack on asm) all pass again. They were reading pre.textContent which silently joined sibling divs without the \n separators they expected; a single text node with embedded \n round-trips the assertion exactly. 11/11 functional tests green.
467 lines
18 KiB
JavaScript
467 lines
18 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 — unlock the vault below to persist this code
|
|
; encrypted in localStorage with your password
|
|
|
|
(print "67")
|
|
(/ 42 6)
|
|
`;
|
|
|
|
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;
|
|
let liveBlock = null;
|
|
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 === "chunk-batch") {
|
|
// Worker batches tight-loop output into 4KB/64-line
|
|
// postMessages. Split back into individual text+eol
|
|
// calls on the playground side too.
|
|
if (!liveBlock) liveBlock = startLiveBlock(tier);
|
|
const t = e.data.text;
|
|
let start = 0;
|
|
for (let i = 0; i < t.length; i++) {
|
|
if (t.charCodeAt(i) === 10) {
|
|
if (i > start) liveBlock.appendText(t.slice(start, i));
|
|
liveBlock.appendNewline();
|
|
start = i + 1;
|
|
}
|
|
}
|
|
if (start < t.length) liveBlock.appendText(t.slice(start));
|
|
} else if (e.data.kind === "chunk-text") {
|
|
if (!liveBlock) liveBlock = startLiveBlock(tier);
|
|
liveBlock.appendText(e.data.text);
|
|
} else if (e.data.kind === "chunk-eol") {
|
|
if (!liveBlock) liveBlock = startLiveBlock(tier);
|
|
liveBlock.appendNewline();
|
|
} else if (e.data.kind === "done") {
|
|
w.removeEventListener("message", handler);
|
|
workerState.pending[tier] = null;
|
|
resolve({ output: e.data.output, liveBlock });
|
|
} else if (e.data.kind === "error") {
|
|
w.removeEventListener("message", handler);
|
|
workerState.pending[tier] = null;
|
|
const err = new Error(e.data.message);
|
|
err.liveBlock = liveBlock;
|
|
reject(err);
|
|
}
|
|
};
|
|
w.addEventListener("message", handler);
|
|
w.postMessage({ kind: "eval", runId: myRunId, tier, src });
|
|
});
|
|
}
|
|
|
|
// Spawn a tier-block in the output panel immediately on first streamed
|
|
// chunk. Returns handles to append further chunks and to finalize the
|
|
// timing header once the eval reports done. Mirrors appendBlock's
|
|
// structure so finalized live blocks look identical to non-streamed
|
|
// ones — same DOM, same CSS.
|
|
function startLiveBlock(tierName) {
|
|
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";
|
|
t.textContent = " (running…)";
|
|
h.appendChild(t);
|
|
block.appendChild(h);
|
|
const pre = document.createElement("pre");
|
|
pre.textContent = "";
|
|
block.appendChild(pre);
|
|
outputEl.appendChild(block);
|
|
|
|
// Single Text node accumulator — appendData is ~1000x faster
|
|
// than appendChild(div) per line on high-volume streams. The
|
|
// <pre> wrapper already preserves \n as a real line break, so
|
|
// we don't need per-line elements. RAF coalesces chunk pile-ups
|
|
// so a tight (display) loop can't drown the main thread.
|
|
pre.style.whiteSpace = "pre";
|
|
const textNode = document.createTextNode("");
|
|
pre.appendChild(textNode);
|
|
let buffered = "";
|
|
let rafPending = false;
|
|
function flushBuffered() {
|
|
rafPending = false;
|
|
if (!buffered) return;
|
|
textNode.appendData(buffered);
|
|
buffered = "";
|
|
outputEl.scrollTop = outputEl.scrollHeight;
|
|
}
|
|
function scheduleFlush() {
|
|
if (rafPending) return;
|
|
rafPending = true;
|
|
requestAnimationFrame(flushBuffered);
|
|
}
|
|
|
|
return {
|
|
appendText(t) {
|
|
if (!t) return;
|
|
buffered += t;
|
|
scheduleFlush();
|
|
},
|
|
appendNewline() {
|
|
buffered += "\n";
|
|
scheduleFlush();
|
|
},
|
|
finalize(elapsedMs, kind, fullOutput) {
|
|
if (kind === "cancelled") t.textContent = ` (cancelled @ ${elapsedMs.toFixed(0)} ms)`;
|
|
else t.textContent = ` (${elapsedMs.toFixed(0)} ms)`;
|
|
if (kind === "error") pre.className = "err";
|
|
// Drain any RAF-buffered chunks then reconcile against the
|
|
// full eval output — covers the asm-tier fallback where
|
|
// streaming isn't wired and the live text node stays empty
|
|
// until finalize lands the canonical value.
|
|
if (rafPending) flushBuffered();
|
|
if (fullOutput) {
|
|
const got = textNode.nodeValue.replace(/\n$/, "");
|
|
if (got !== fullOutput.replace(/\n$/, "")) {
|
|
textNode.nodeValue = fullOutput;
|
|
}
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
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;
|
|
// bend demo guard: refuse to run locally without a configured bend URL.
|
|
// A CPU-impossible workload would lock the customer's tab.
|
|
const program = document.querySelector('input[name="program"]:checked').value;
|
|
if (program === "bend-gpu" && !bendState.url) {
|
|
setStatus("set a bend URL first — this demo is GPU-only by design", "err");
|
|
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, liveBlock }) => {
|
|
const elapsed = performance.now() - startTimes[t];
|
|
delete startTimes[t];
|
|
// Streamed tiers already painted via liveBlock; just
|
|
// finalize the timing header. Non-streaming tiers
|
|
// (today: asm) get a fresh appendBlock at the end.
|
|
if (liveBlock) liveBlock.finalize(elapsed, "ok", output);
|
|
else 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") {
|
|
if (e.liveBlock) e.liveBlock.finalize(elapsed, "cancelled");
|
|
else appendBlock(t, elapsed, "(cancelled)", "cancelled");
|
|
return { tier: t, cancelled: true, elapsed };
|
|
}
|
|
if (e.liveBlock) e.liveBlock.finalize(elapsed, "error", e.message);
|
|
else 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();
|