diff --git a/wasm/app/app.js b/wasm/app/app.js
index a739eac..bd26c0a 100644
--- a/wasm/app/app.js
+++ b/wasm/app/app.js
@@ -12,7 +12,35 @@ 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\n; encrypted in localStorage with your password\n\n(+ 1 2)\n";
+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.
@@ -166,7 +194,9 @@ const workerState = {
};
function spawnWorker() {
- return new Worker(new URL("./worker.mjs", import.meta.url), { type: "module" });
+ 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) {
@@ -174,6 +204,34 @@ function ensureWorker(tier) {
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);
diff --git a/wasm/app/index.html b/wasm/app/index.html
index 03a27f3..b664386 100644
--- a/wasm/app/index.html
+++ b/wasm/app/index.html
@@ -59,6 +59,15 @@
+
+
🔒
locked
diff --git a/wasm/app/runner.js b/wasm/app/runner.js
index 1b52b7b..bad777c 100644
--- a/wasm/app/runner.js
+++ b/wasm/app/runner.js
@@ -8,6 +8,15 @@ import { createCTier } from "./c/lumbda-c.loader.js";
import { createAsmTier } from "./asm/lumbda-asm.loader.js";
const cache = {};
+const config = { bendUrl: null };
+
+export function setBendUrl(url) {
+ config.bendUrl = url || null;
+ // Propagate to already-loaded tiers.
+ for (const t of Object.values(cache)) {
+ if (t && t.setBendUrl) t.setBendUrl(config.bendUrl);
+ }
+}
export async function getTier(name, onLoad) {
if (cache[name]) return cache[name];
@@ -16,6 +25,7 @@ export async function getTier(name, onLoad) {
else if (name === "c") cache[name] = await createCTier();
else if (name === "asm") cache[name] = await createAsmTier();
else throw new Error(`unknown tier: ${name}`);
+ if (cache[name].setBendUrl) cache[name].setBendUrl(config.bendUrl);
return cache[name];
}
diff --git a/wasm/app/style.css b/wasm/app/style.css
index 2082544..e62e45c 100644
--- a/wasm/app/style.css
+++ b/wasm/app/style.css
@@ -42,6 +42,12 @@
* { box-sizing: border-box; }
+html {
+ /* Default zoom so text is comfortably readable. Native CSS zoom
+ * preserves layout proportions across the whole document. */
+ zoom: 1.33;
+}
+
html, body {
margin: 0; padding: 0;
background: var(--bg);
@@ -189,6 +195,46 @@ header code {
.controls .status.err { color: var(--err); }
.controls .status.ok { color: var(--green); }
+.bend-bar {
+ padding: 0.4rem 1.5rem;
+ border-bottom: 1px solid var(--rule);
+ display: flex; align-items: center; gap: 0.6rem;
+ background: var(--bg);
+ font-size: 0.85em;
+}
+.bend-bar .bend-icon { font-size: 1em; }
+.bend-bar label {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ color: var(--muted);
+}
+.bend-bar input[type=url] {
+ flex: 1;
+ background: var(--code-bg);
+ border: 1px solid var(--rule);
+ color: var(--fg);
+ padding: 0.3rem 0.5rem;
+ font-family: var(--mono);
+ font-size: 0.9em;
+ border-radius: 3px;
+}
+.bend-bar input[type=url]:focus { outline: none; border-color: var(--green); }
+.bend-bar button {
+ background: transparent;
+ color: var(--green);
+ border: 1px solid var(--green);
+ font-family: var(--mono);
+ font-size: 0.85em;
+ font-weight: 600;
+ padding: 0.3rem 0.8rem;
+ border-radius: 3px;
+ cursor: pointer;
+}
+.bend-bar button:hover { background: var(--code-bg); }
+.bend-bar .bend-state { color: var(--green); font-size: 0.8em; min-width: 4em; }
+
.vault-bar {
padding: 0.5rem 1.5rem;
border-bottom: 1px solid var(--rule);
diff --git a/wasm/app/worker.mjs b/wasm/app/worker.mjs
index 62047e6..9fbc7a5 100644
--- a/wasm/app/worker.mjs
+++ b/wasm/app/worker.mjs
@@ -3,11 +3,16 @@
// live ms counter actually ticks AND so cancel works (main thread
// terminates this worker via worker.terminate()).
-import { evalOnTier } from "./runner.js";
+import { evalOnTier, setBendUrl } from "./runner.js";
self.onmessage = async (e) => {
- const { kind, runId, tier, src } = e.data;
+ const { kind } = e.data;
+ if (kind === "config") {
+ setBendUrl(e.data.bendUrl);
+ return;
+ }
if (kind !== "eval") return;
+ const { runId, tier, src } = e.data;
try {
const output = await evalOnTier(tier, src, (loadingTier) => {
self.postMessage({ kind: "loading", runId, tier: loadingTier });
diff --git a/wasm/asm/lumbda-asm.loader.js b/wasm/asm/lumbda-asm.loader.js
index 8ff3a24..8da41de 100644
--- a/wasm/asm/lumbda-asm.loader.js
+++ b/wasm/asm/lumbda-asm.loader.js
@@ -4,18 +4,57 @@
// Memory layout (mirrors lumbda.wat):
// 0x10000 output buffer (read after each call)
// 0x20000 source buffer (write before each call)
+// 0x40000 bend response scratch (host writes here when JS dispatches)
async function _bootstrap() {
const wasmURL = new URL("./lumbda-asm.wasm", import.meta.url).href;
const resp = await fetch(wasmURL);
const bytes = await resp.arrayBuffer();
- const { instance } = await WebAssembly.instantiate(bytes);
- const exp = instance.exports;
- exp.lumbda_init();
+ // Closure-captured holder so the imported function can see the
+ // instance's memory after instantiation completes.
+ const refs = { instance: null, bendUrl: null };
+
+ const importObj = {
+ env: {
+ // bend_call(src_ptr, src_len, resp_ptr) -> resp_len.
+ // Synchronous XHR is the only sync HTTP available in workers;
+ // perfect for the WAT tier's blocking eval model.
+ bend_call(srcPtr, srcLen, respPtr) {
+ if (!refs.bendUrl) {
+ const msg = "no bend URL configured";
+ const mem = new Uint8Array(refs.instance.exports.memory.buffer);
+ mem.set(new TextEncoder().encode(msg), respPtr);
+ return msg.length;
+ }
+ const mem = new Uint8Array(refs.instance.exports.memory.buffer);
+ const payload = new TextDecoder().decode(mem.slice(srcPtr, srcPtr + srcLen));
+ try {
+ const xhr = new XMLHttpRequest();
+ xhr.open("POST", refs.bendUrl, false); // sync
+ xhr.setRequestHeader("Content-Type", "text/plain; charset=utf-8");
+ xhr.send(payload);
+ const respText = xhr.responseText || "";
+ const respBytes = new TextEncoder().encode(respText);
+ new Uint8Array(refs.instance.exports.memory.buffer).set(respBytes, respPtr);
+ return respBytes.length;
+ } catch (e) {
+ const err = "bend error: " + (e.message || String(e));
+ new Uint8Array(refs.instance.exports.memory.buffer)
+ .set(new TextEncoder().encode(err), respPtr);
+ return err.length;
+ }
+ },
+ },
+ };
+
+ const { instance } = await WebAssembly.instantiate(bytes, importObj);
+ refs.instance = instance;
+ instance.exports.lumbda_init();
const enc = new TextEncoder();
const dec = new TextDecoder();
+ const exp = instance.exports;
return {
async evalLisp(src) {
@@ -32,6 +71,7 @@ async function _bootstrap() {
const outLen = exp.lumbda_output_len();
return dec.decode(new Uint8Array(exp.memory.buffer, outPtr, outLen));
},
+ setBendUrl(url) { refs.bendUrl = url || null; },
};
}
diff --git a/wasm/asm/lumbda.wat b/wasm/asm/lumbda.wat
index 431f224..aaf491c 100644
--- a/wasm/asm/lumbda.wat
+++ b/wasm/asm/lumbda.wat
@@ -38,6 +38,14 @@
;; Built-in symbols (interned at init) live early on the heap.
(module
+ ;; ─── Imports from host JS ──────────────────────────────────────
+ ;; bend_call(src_ptr, src_len, resp_ptr) -> resp_len.
+ ;; JS does a synchronous XHR to the configured gpu-worker URL,
+ ;; writes the response bytes at resp_ptr, returns the byte length.
+ ;; Worker context only — sync XHR isn't allowed on the main thread.
+ (import "env" "bend_call"
+ (func $js_bend_call (param i32 i32 i32) (result i32)))
+
;; ─── Memory & exports ──────────────────────────────────────────
(memory (export "memory") 32 4096) ;; 32 pages = 2 MB initial, grow to 256 MB
@@ -1943,6 +1951,35 @@
(br $l)))
(local.get $head))
+ ;; ─── Bend dispatch ─────────────────────────────────────────────
+ ;; Send a payload string to the configured gpu-worker via host XHR.
+ ;; Response is written into the scratch area at 0x40000 (64 KB), then
+ ;; copied into a freshly-allocated lumbda string.
+ (func $bend_call_op (param $payload i32) (result i32)
+ (local $resp_buf i32)
+ (local $resp_len i32)
+ (local $payload_ptr i32)
+ (local $payload_len i32)
+ (local $out i32)
+ (local $i i32)
+ (if (i32.eqz (call $is_string (local.get $payload)))
+ (then (return (global.get $FALSE))))
+ (local.set $resp_buf (i32.const 0x40000))
+ (local.set $payload_ptr (i32.add (local.get $payload) (i32.const 8)))
+ (local.set $payload_len (call $string_len (local.get $payload)))
+ (local.set $resp_len
+ (call $js_bend_call (local.get $payload_ptr) (local.get $payload_len) (local.get $resp_buf)))
+ (local.set $out (call $make_string_raw (local.get $resp_len)))
+ (local.set $i (i32.const 0))
+ (block $done
+ (loop $l
+ (br_if $done (i32.ge_u (local.get $i) (local.get $resp_len)))
+ (call $string_set_byte (local.get $out) (local.get $i)
+ (i32.load8_u (i32.add (local.get $resp_buf) (local.get $i))))
+ (local.set $i (i32.add (local.get $i) (i32.const 1)))
+ (br $l)))
+ (local.get $out))
+
;; ─── Primitives ────────────────────────────────────────────────
(func $apply_primitive (param $id i32) (param $args i32) (result i32)
(local $a i32)
@@ -2668,6 +2705,11 @@
(call $string_len (local.get $a)))))
(return (global.get $VOID))))
+ ;; bend!-call (109) — dispatch a payload string to the configured
+ ;; gpu-worker URL via host JS sync XHR. Returns the response as a string.
+ (if (i32.eq (local.get $id) (i32.const 109))
+ (then (return (call $bend_call_op (local.get $a)))))
+
(global.get $VOID))
;; ─── Init ──────────────────────────────────────────────────────
@@ -2821,6 +2863,7 @@
(data (i32.const 0xE5A4) "hash-table->alist")
(data (i32.const 0xE5B8) "write")
(data (i32.const 0xE5C0) "write-string")
+ (data (i32.const 0xE5D0) "bend!-call")
;; ── Embedded Lisp prelude. Evaluated at end of init. ─────────────
(data (i32.const 0x50000)
@@ -3009,6 +3052,7 @@
(call $bind_prim (i32.const 0xE5A4) (i32.const 17) (i32.const 106)) ;; hash-table->alist
(call $bind_prim (i32.const 0xE5B8) (i32.const 5) (i32.const 107)) ;; write
(call $bind_prim (i32.const 0xE5C0) (i32.const 12) (i32.const 108)) ;; write-string
+ (call $bind_prim (i32.const 0xE5D0) (i32.const 10) (i32.const 109)) ;; bend!-call
(call $eval_prelude))
;; ─── Public eval entry ─────────────────────────────────────────
diff --git a/wasm/dist-repl/asm/lumbda-asm.loader.js b/wasm/dist-repl/asm/lumbda-asm.loader.js
index 8ff3a24..8da41de 100644
--- a/wasm/dist-repl/asm/lumbda-asm.loader.js
+++ b/wasm/dist-repl/asm/lumbda-asm.loader.js
@@ -4,18 +4,57 @@
// Memory layout (mirrors lumbda.wat):
// 0x10000 output buffer (read after each call)
// 0x20000 source buffer (write before each call)
+// 0x40000 bend response scratch (host writes here when JS dispatches)
async function _bootstrap() {
const wasmURL = new URL("./lumbda-asm.wasm", import.meta.url).href;
const resp = await fetch(wasmURL);
const bytes = await resp.arrayBuffer();
- const { instance } = await WebAssembly.instantiate(bytes);
- const exp = instance.exports;
- exp.lumbda_init();
+ // Closure-captured holder so the imported function can see the
+ // instance's memory after instantiation completes.
+ const refs = { instance: null, bendUrl: null };
+
+ const importObj = {
+ env: {
+ // bend_call(src_ptr, src_len, resp_ptr) -> resp_len.
+ // Synchronous XHR is the only sync HTTP available in workers;
+ // perfect for the WAT tier's blocking eval model.
+ bend_call(srcPtr, srcLen, respPtr) {
+ if (!refs.bendUrl) {
+ const msg = "no bend URL configured";
+ const mem = new Uint8Array(refs.instance.exports.memory.buffer);
+ mem.set(new TextEncoder().encode(msg), respPtr);
+ return msg.length;
+ }
+ const mem = new Uint8Array(refs.instance.exports.memory.buffer);
+ const payload = new TextDecoder().decode(mem.slice(srcPtr, srcPtr + srcLen));
+ try {
+ const xhr = new XMLHttpRequest();
+ xhr.open("POST", refs.bendUrl, false); // sync
+ xhr.setRequestHeader("Content-Type", "text/plain; charset=utf-8");
+ xhr.send(payload);
+ const respText = xhr.responseText || "";
+ const respBytes = new TextEncoder().encode(respText);
+ new Uint8Array(refs.instance.exports.memory.buffer).set(respBytes, respPtr);
+ return respBytes.length;
+ } catch (e) {
+ const err = "bend error: " + (e.message || String(e));
+ new Uint8Array(refs.instance.exports.memory.buffer)
+ .set(new TextEncoder().encode(err), respPtr);
+ return err.length;
+ }
+ },
+ },
+ };
+
+ const { instance } = await WebAssembly.instantiate(bytes, importObj);
+ refs.instance = instance;
+ instance.exports.lumbda_init();
const enc = new TextEncoder();
const dec = new TextDecoder();
+ const exp = instance.exports;
return {
async evalLisp(src) {
@@ -32,6 +71,7 @@ async function _bootstrap() {
const outLen = exp.lumbda_output_len();
return dec.decode(new Uint8Array(exp.memory.buffer, outPtr, outLen));
},
+ setBendUrl(url) { refs.bendUrl = url || null; },
};
}
diff --git a/wasm/dist-repl/asm/lumbda-asm.wasm b/wasm/dist-repl/asm/lumbda-asm.wasm
index f22fa17..a46b70e 100644
Binary files a/wasm/dist-repl/asm/lumbda-asm.wasm and b/wasm/dist-repl/asm/lumbda-asm.wasm differ
diff --git a/wasm/dist-repl/runner.js b/wasm/dist-repl/runner.js
index 1b52b7b..bad777c 100644
--- a/wasm/dist-repl/runner.js
+++ b/wasm/dist-repl/runner.js
@@ -8,6 +8,15 @@ import { createCTier } from "./c/lumbda-c.loader.js";
import { createAsmTier } from "./asm/lumbda-asm.loader.js";
const cache = {};
+const config = { bendUrl: null };
+
+export function setBendUrl(url) {
+ config.bendUrl = url || null;
+ // Propagate to already-loaded tiers.
+ for (const t of Object.values(cache)) {
+ if (t && t.setBendUrl) t.setBendUrl(config.bendUrl);
+ }
+}
export async function getTier(name, onLoad) {
if (cache[name]) return cache[name];
@@ -16,6 +25,7 @@ export async function getTier(name, onLoad) {
else if (name === "c") cache[name] = await createCTier();
else if (name === "asm") cache[name] = await createAsmTier();
else throw new Error(`unknown tier: ${name}`);
+ if (cache[name].setBendUrl) cache[name].setBendUrl(config.bendUrl);
return cache[name];
}
diff --git a/wasm/dist-repl/style.css b/wasm/dist-repl/style.css
index 2082544..e62e45c 100644
--- a/wasm/dist-repl/style.css
+++ b/wasm/dist-repl/style.css
@@ -42,6 +42,12 @@
* { box-sizing: border-box; }
+html {
+ /* Default zoom so text is comfortably readable. Native CSS zoom
+ * preserves layout proportions across the whole document. */
+ zoom: 1.33;
+}
+
html, body {
margin: 0; padding: 0;
background: var(--bg);
@@ -189,6 +195,46 @@ header code {
.controls .status.err { color: var(--err); }
.controls .status.ok { color: var(--green); }
+.bend-bar {
+ padding: 0.4rem 1.5rem;
+ border-bottom: 1px solid var(--rule);
+ display: flex; align-items: center; gap: 0.6rem;
+ background: var(--bg);
+ font-size: 0.85em;
+}
+.bend-bar .bend-icon { font-size: 1em; }
+.bend-bar label {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ color: var(--muted);
+}
+.bend-bar input[type=url] {
+ flex: 1;
+ background: var(--code-bg);
+ border: 1px solid var(--rule);
+ color: var(--fg);
+ padding: 0.3rem 0.5rem;
+ font-family: var(--mono);
+ font-size: 0.9em;
+ border-radius: 3px;
+}
+.bend-bar input[type=url]:focus { outline: none; border-color: var(--green); }
+.bend-bar button {
+ background: transparent;
+ color: var(--green);
+ border: 1px solid var(--green);
+ font-family: var(--mono);
+ font-size: 0.85em;
+ font-weight: 600;
+ padding: 0.3rem 0.8rem;
+ border-radius: 3px;
+ cursor: pointer;
+}
+.bend-bar button:hover { background: var(--code-bg); }
+.bend-bar .bend-state { color: var(--green); font-size: 0.8em; min-width: 4em; }
+
.vault-bar {
padding: 0.5rem 1.5rem;
border-bottom: 1px solid var(--rule);
diff --git a/wasm/dist-repl/worker.mjs b/wasm/dist-repl/worker.mjs
index 62047e6..9fbc7a5 100644
--- a/wasm/dist-repl/worker.mjs
+++ b/wasm/dist-repl/worker.mjs
@@ -3,11 +3,16 @@
// live ms counter actually ticks AND so cancel works (main thread
// terminates this worker via worker.terminate()).
-import { evalOnTier } from "./runner.js";
+import { evalOnTier, setBendUrl } from "./runner.js";
self.onmessage = async (e) => {
- const { kind, runId, tier, src } = e.data;
+ const { kind } = e.data;
+ if (kind === "config") {
+ setBendUrl(e.data.bendUrl);
+ return;
+ }
if (kind !== "eval") return;
+ const { runId, tier, src } = e.data;
try {
const output = await evalOnTier(tier, src, (loadingTier) => {
self.postMessage({ kind: "loading", runId, tier: loadingTier });
diff --git a/wasm/tests/functional-cross.mjs b/wasm/tests/functional-cross.mjs
index abd105b..e6fea2f 100644
--- a/wasm/tests/functional-cross.mjs
+++ b/wasm/tests/functional-cross.mjs
@@ -19,7 +19,8 @@ function countPassFail(out) {
async function runAsm() {
const wasmBytes = fs.readFileSync(path.join(dist, "asm", "lumbda-asm.wasm"));
- const { instance } = await WebAssembly.instantiate(wasmBytes);
+ const importObj = { env: { bend_call(_p, _l, _r) { return 0; } } };
+ const { instance } = await WebAssembly.instantiate(wasmBytes, importObj);
const exp = instance.exports;
exp.lumbda_init();
const bytes = new TextEncoder().encode(functionalLsp);
diff --git a/wasm/tests/integration.mjs b/wasm/tests/integration.mjs
index 58b17fd..1b25147 100644
--- a/wasm/tests/integration.mjs
+++ b/wasm/tests/integration.mjs
@@ -31,7 +31,8 @@ function nativePython(demoPath) {
async function runAsm(src) {
const wasmBytes = fs.readFileSync(path.join(dist, "asm", "lumbda-asm.wasm"));
- const { instance } = await WebAssembly.instantiate(wasmBytes);
+ const importObj = { env: { bend_call(_p, _l, _r) { return 0; } } };
+ const { instance } = await WebAssembly.instantiate(wasmBytes, importObj);
const exp = instance.exports;
exp.lumbda_init();
const bytes = new TextEncoder().encode(src);
diff --git a/wasm/tests/unit.mjs b/wasm/tests/unit.mjs
index 409612b..fd0daff 100644
--- a/wasm/tests/unit.mjs
+++ b/wasm/tests/unit.mjs
@@ -23,7 +23,8 @@ function check(name, cond, detail) {
async function testAsm() {
console.log("── asm tier ──");
const wasmBytes = fs.readFileSync(path.join(dist, "asm", "lumbda-asm.wasm"));
- const { instance } = await WebAssembly.instantiate(wasmBytes);
+ const importObj = { env: { bend_call(_p, _l, _r) { return 0; } } };
+ const { instance } = await WebAssembly.instantiate(wasmBytes, importObj);
const exp = instance.exports;
exp.lumbda_init();
diff --git a/www/playground/app.js b/www/playground/app.js
index a739eac..bd26c0a 100644
--- a/www/playground/app.js
+++ b/www/playground/app.js
@@ -12,7 +12,35 @@ 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\n; encrypted in localStorage with your password\n\n(+ 1 2)\n";
+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.
@@ -166,7 +194,9 @@ const workerState = {
};
function spawnWorker() {
- return new Worker(new URL("./worker.mjs", import.meta.url), { type: "module" });
+ 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) {
@@ -174,6 +204,34 @@ function ensureWorker(tier) {
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);
diff --git a/www/playground/asm/lumbda-asm.loader.js b/www/playground/asm/lumbda-asm.loader.js
index 8ff3a24..8da41de 100644
--- a/www/playground/asm/lumbda-asm.loader.js
+++ b/www/playground/asm/lumbda-asm.loader.js
@@ -4,18 +4,57 @@
// Memory layout (mirrors lumbda.wat):
// 0x10000 output buffer (read after each call)
// 0x20000 source buffer (write before each call)
+// 0x40000 bend response scratch (host writes here when JS dispatches)
async function _bootstrap() {
const wasmURL = new URL("./lumbda-asm.wasm", import.meta.url).href;
const resp = await fetch(wasmURL);
const bytes = await resp.arrayBuffer();
- const { instance } = await WebAssembly.instantiate(bytes);
- const exp = instance.exports;
- exp.lumbda_init();
+ // Closure-captured holder so the imported function can see the
+ // instance's memory after instantiation completes.
+ const refs = { instance: null, bendUrl: null };
+
+ const importObj = {
+ env: {
+ // bend_call(src_ptr, src_len, resp_ptr) -> resp_len.
+ // Synchronous XHR is the only sync HTTP available in workers;
+ // perfect for the WAT tier's blocking eval model.
+ bend_call(srcPtr, srcLen, respPtr) {
+ if (!refs.bendUrl) {
+ const msg = "no bend URL configured";
+ const mem = new Uint8Array(refs.instance.exports.memory.buffer);
+ mem.set(new TextEncoder().encode(msg), respPtr);
+ return msg.length;
+ }
+ const mem = new Uint8Array(refs.instance.exports.memory.buffer);
+ const payload = new TextDecoder().decode(mem.slice(srcPtr, srcPtr + srcLen));
+ try {
+ const xhr = new XMLHttpRequest();
+ xhr.open("POST", refs.bendUrl, false); // sync
+ xhr.setRequestHeader("Content-Type", "text/plain; charset=utf-8");
+ xhr.send(payload);
+ const respText = xhr.responseText || "";
+ const respBytes = new TextEncoder().encode(respText);
+ new Uint8Array(refs.instance.exports.memory.buffer).set(respBytes, respPtr);
+ return respBytes.length;
+ } catch (e) {
+ const err = "bend error: " + (e.message || String(e));
+ new Uint8Array(refs.instance.exports.memory.buffer)
+ .set(new TextEncoder().encode(err), respPtr);
+ return err.length;
+ }
+ },
+ },
+ };
+
+ const { instance } = await WebAssembly.instantiate(bytes, importObj);
+ refs.instance = instance;
+ instance.exports.lumbda_init();
const enc = new TextEncoder();
const dec = new TextDecoder();
+ const exp = instance.exports;
return {
async evalLisp(src) {
@@ -32,6 +71,7 @@ async function _bootstrap() {
const outLen = exp.lumbda_output_len();
return dec.decode(new Uint8Array(exp.memory.buffer, outPtr, outLen));
},
+ setBendUrl(url) { refs.bendUrl = url || null; },
};
}
diff --git a/www/playground/asm/lumbda-asm.wasm b/www/playground/asm/lumbda-asm.wasm
index f22fa17..a46b70e 100644
Binary files a/www/playground/asm/lumbda-asm.wasm and b/www/playground/asm/lumbda-asm.wasm differ
diff --git a/www/playground/index.html b/www/playground/index.html
index 03a27f3..b664386 100644
--- a/www/playground/index.html
+++ b/www/playground/index.html
@@ -59,6 +59,15 @@
+
+
🔒
locked
diff --git a/www/playground/runner.js b/www/playground/runner.js
index 1b52b7b..bad777c 100644
--- a/www/playground/runner.js
+++ b/www/playground/runner.js
@@ -8,6 +8,15 @@ import { createCTier } from "./c/lumbda-c.loader.js";
import { createAsmTier } from "./asm/lumbda-asm.loader.js";
const cache = {};
+const config = { bendUrl: null };
+
+export function setBendUrl(url) {
+ config.bendUrl = url || null;
+ // Propagate to already-loaded tiers.
+ for (const t of Object.values(cache)) {
+ if (t && t.setBendUrl) t.setBendUrl(config.bendUrl);
+ }
+}
export async function getTier(name, onLoad) {
if (cache[name]) return cache[name];
@@ -16,6 +25,7 @@ export async function getTier(name, onLoad) {
else if (name === "c") cache[name] = await createCTier();
else if (name === "asm") cache[name] = await createAsmTier();
else throw new Error(`unknown tier: ${name}`);
+ if (cache[name].setBendUrl) cache[name].setBendUrl(config.bendUrl);
return cache[name];
}
diff --git a/www/playground/style.css b/www/playground/style.css
index 2082544..e62e45c 100644
--- a/www/playground/style.css
+++ b/www/playground/style.css
@@ -42,6 +42,12 @@
* { box-sizing: border-box; }
+html {
+ /* Default zoom so text is comfortably readable. Native CSS zoom
+ * preserves layout proportions across the whole document. */
+ zoom: 1.33;
+}
+
html, body {
margin: 0; padding: 0;
background: var(--bg);
@@ -189,6 +195,46 @@ header code {
.controls .status.err { color: var(--err); }
.controls .status.ok { color: var(--green); }
+.bend-bar {
+ padding: 0.4rem 1.5rem;
+ border-bottom: 1px solid var(--rule);
+ display: flex; align-items: center; gap: 0.6rem;
+ background: var(--bg);
+ font-size: 0.85em;
+}
+.bend-bar .bend-icon { font-size: 1em; }
+.bend-bar label {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ color: var(--muted);
+}
+.bend-bar input[type=url] {
+ flex: 1;
+ background: var(--code-bg);
+ border: 1px solid var(--rule);
+ color: var(--fg);
+ padding: 0.3rem 0.5rem;
+ font-family: var(--mono);
+ font-size: 0.9em;
+ border-radius: 3px;
+}
+.bend-bar input[type=url]:focus { outline: none; border-color: var(--green); }
+.bend-bar button {
+ background: transparent;
+ color: var(--green);
+ border: 1px solid var(--green);
+ font-family: var(--mono);
+ font-size: 0.85em;
+ font-weight: 600;
+ padding: 0.3rem 0.8rem;
+ border-radius: 3px;
+ cursor: pointer;
+}
+.bend-bar button:hover { background: var(--code-bg); }
+.bend-bar .bend-state { color: var(--green); font-size: 0.8em; min-width: 4em; }
+
.vault-bar {
padding: 0.5rem 1.5rem;
border-bottom: 1px solid var(--rule);
diff --git a/www/playground/worker.mjs b/www/playground/worker.mjs
index 62047e6..9fbc7a5 100644
--- a/www/playground/worker.mjs
+++ b/www/playground/worker.mjs
@@ -3,11 +3,16 @@
// live ms counter actually ticks AND so cancel works (main thread
// terminates this worker via worker.terminate()).
-import { evalOnTier } from "./runner.js";
+import { evalOnTier, setBendUrl } from "./runner.js";
self.onmessage = async (e) => {
- const { kind, runId, tier, src } = e.data;
+ const { kind } = e.data;
+ if (kind === "config") {
+ setBendUrl(e.data.bendUrl);
+ return;
+ }
if (kind !== "eval") return;
+ const { runId, tier, src } = e.data;
try {
const output = await evalOnTier(tier, src, (loadingTier) => {
self.postMessage({ kind: "loading", runId, tier: loadingTier });
diff --git a/www/repl/asm/lumbda-asm.loader.js b/www/repl/asm/lumbda-asm.loader.js
index 8ff3a24..8da41de 100644
--- a/www/repl/asm/lumbda-asm.loader.js
+++ b/www/repl/asm/lumbda-asm.loader.js
@@ -4,18 +4,57 @@
// Memory layout (mirrors lumbda.wat):
// 0x10000 output buffer (read after each call)
// 0x20000 source buffer (write before each call)
+// 0x40000 bend response scratch (host writes here when JS dispatches)
async function _bootstrap() {
const wasmURL = new URL("./lumbda-asm.wasm", import.meta.url).href;
const resp = await fetch(wasmURL);
const bytes = await resp.arrayBuffer();
- const { instance } = await WebAssembly.instantiate(bytes);
- const exp = instance.exports;
- exp.lumbda_init();
+ // Closure-captured holder so the imported function can see the
+ // instance's memory after instantiation completes.
+ const refs = { instance: null, bendUrl: null };
+
+ const importObj = {
+ env: {
+ // bend_call(src_ptr, src_len, resp_ptr) -> resp_len.
+ // Synchronous XHR is the only sync HTTP available in workers;
+ // perfect for the WAT tier's blocking eval model.
+ bend_call(srcPtr, srcLen, respPtr) {
+ if (!refs.bendUrl) {
+ const msg = "no bend URL configured";
+ const mem = new Uint8Array(refs.instance.exports.memory.buffer);
+ mem.set(new TextEncoder().encode(msg), respPtr);
+ return msg.length;
+ }
+ const mem = new Uint8Array(refs.instance.exports.memory.buffer);
+ const payload = new TextDecoder().decode(mem.slice(srcPtr, srcPtr + srcLen));
+ try {
+ const xhr = new XMLHttpRequest();
+ xhr.open("POST", refs.bendUrl, false); // sync
+ xhr.setRequestHeader("Content-Type", "text/plain; charset=utf-8");
+ xhr.send(payload);
+ const respText = xhr.responseText || "";
+ const respBytes = new TextEncoder().encode(respText);
+ new Uint8Array(refs.instance.exports.memory.buffer).set(respBytes, respPtr);
+ return respBytes.length;
+ } catch (e) {
+ const err = "bend error: " + (e.message || String(e));
+ new Uint8Array(refs.instance.exports.memory.buffer)
+ .set(new TextEncoder().encode(err), respPtr);
+ return err.length;
+ }
+ },
+ },
+ };
+
+ const { instance } = await WebAssembly.instantiate(bytes, importObj);
+ refs.instance = instance;
+ instance.exports.lumbda_init();
const enc = new TextEncoder();
const dec = new TextDecoder();
+ const exp = instance.exports;
return {
async evalLisp(src) {
@@ -32,6 +71,7 @@ async function _bootstrap() {
const outLen = exp.lumbda_output_len();
return dec.decode(new Uint8Array(exp.memory.buffer, outPtr, outLen));
},
+ setBendUrl(url) { refs.bendUrl = url || null; },
};
}
diff --git a/www/repl/asm/lumbda-asm.wasm b/www/repl/asm/lumbda-asm.wasm
index f22fa17..a46b70e 100644
Binary files a/www/repl/asm/lumbda-asm.wasm and b/www/repl/asm/lumbda-asm.wasm differ
diff --git a/www/repl/runner.js b/www/repl/runner.js
index 1b52b7b..bad777c 100644
--- a/www/repl/runner.js
+++ b/www/repl/runner.js
@@ -8,6 +8,15 @@ import { createCTier } from "./c/lumbda-c.loader.js";
import { createAsmTier } from "./asm/lumbda-asm.loader.js";
const cache = {};
+const config = { bendUrl: null };
+
+export function setBendUrl(url) {
+ config.bendUrl = url || null;
+ // Propagate to already-loaded tiers.
+ for (const t of Object.values(cache)) {
+ if (t && t.setBendUrl) t.setBendUrl(config.bendUrl);
+ }
+}
export async function getTier(name, onLoad) {
if (cache[name]) return cache[name];
@@ -16,6 +25,7 @@ export async function getTier(name, onLoad) {
else if (name === "c") cache[name] = await createCTier();
else if (name === "asm") cache[name] = await createAsmTier();
else throw new Error(`unknown tier: ${name}`);
+ if (cache[name].setBendUrl) cache[name].setBendUrl(config.bendUrl);
return cache[name];
}
diff --git a/www/repl/style.css b/www/repl/style.css
index 2082544..e62e45c 100644
--- a/www/repl/style.css
+++ b/www/repl/style.css
@@ -42,6 +42,12 @@
* { box-sizing: border-box; }
+html {
+ /* Default zoom so text is comfortably readable. Native CSS zoom
+ * preserves layout proportions across the whole document. */
+ zoom: 1.33;
+}
+
html, body {
margin: 0; padding: 0;
background: var(--bg);
@@ -189,6 +195,46 @@ header code {
.controls .status.err { color: var(--err); }
.controls .status.ok { color: var(--green); }
+.bend-bar {
+ padding: 0.4rem 1.5rem;
+ border-bottom: 1px solid var(--rule);
+ display: flex; align-items: center; gap: 0.6rem;
+ background: var(--bg);
+ font-size: 0.85em;
+}
+.bend-bar .bend-icon { font-size: 1em; }
+.bend-bar label {
+ flex: 1;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ color: var(--muted);
+}
+.bend-bar input[type=url] {
+ flex: 1;
+ background: var(--code-bg);
+ border: 1px solid var(--rule);
+ color: var(--fg);
+ padding: 0.3rem 0.5rem;
+ font-family: var(--mono);
+ font-size: 0.9em;
+ border-radius: 3px;
+}
+.bend-bar input[type=url]:focus { outline: none; border-color: var(--green); }
+.bend-bar button {
+ background: transparent;
+ color: var(--green);
+ border: 1px solid var(--green);
+ font-family: var(--mono);
+ font-size: 0.85em;
+ font-weight: 600;
+ padding: 0.3rem 0.8rem;
+ border-radius: 3px;
+ cursor: pointer;
+}
+.bend-bar button:hover { background: var(--code-bg); }
+.bend-bar .bend-state { color: var(--green); font-size: 0.8em; min-width: 4em; }
+
.vault-bar {
padding: 0.5rem 1.5rem;
border-bottom: 1px solid var(--rule);
diff --git a/www/repl/worker.mjs b/www/repl/worker.mjs
index 62047e6..9fbc7a5 100644
--- a/www/repl/worker.mjs
+++ b/www/repl/worker.mjs
@@ -3,11 +3,16 @@
// live ms counter actually ticks AND so cancel works (main thread
// terminates this worker via worker.terminate()).
-import { evalOnTier } from "./runner.js";
+import { evalOnTier, setBendUrl } from "./runner.js";
self.onmessage = async (e) => {
- const { kind, runId, tier, src } = e.data;
+ const { kind } = e.data;
+ if (kind === "config") {
+ setBendUrl(e.data.bendUrl);
+ return;
+ }
if (kind !== "eval") return;
+ const { runId, tier, src } = e.data;
try {
const output = await evalOnTier(tier, src, (loadingTier) => {
self.postMessage({ kind: "loading", runId, tier: loadingTier });