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.
This commit is contained in:
russell@unturf.com 2026-06-14 13:05:06 -04:00
parent 51b449a83d
commit 7e32eefd6b
No known key found for this signature in database
27 changed files with 612 additions and 27 deletions

View file

@ -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);

View file

@ -59,6 +59,15 @@
<span id="status" class="status"></span>
</section>
<section class="bend-bar">
<span class="bend-icon" title="gpu-worker bend URL"></span>
<label>bend URL
<input id="bend-url" type="url" placeholder="https://your-gpu-worker.example/bend (sets (bend!-call ...) destination)">
</label>
<button id="bend-save" class="secondary">save</button>
<span id="bend-state" class="bend-state"></span>
</section>
<section class="vault-bar" id="vault-bar" hidden>
<span class="vault-icon" title="local vault">🔒</span>
<span id="vault-state" class="vault-state">locked</span>

View file

@ -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];
}

View file

@ -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);

View file

@ -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 });

View file

@ -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; },
};
}

View file

@ -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 ─────────────────────────────────────────

View file

@ -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; },
};
}

Binary file not shown.

View file

@ -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];
}

View file

@ -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);

View file

@ -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 });

View file

@ -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);

View file

@ -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);

View file

@ -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();

View file

@ -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);

View file

@ -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; },
};
}

Binary file not shown.

View file

@ -59,6 +59,15 @@
<span id="status" class="status"></span>
</section>
<section class="bend-bar">
<span class="bend-icon" title="gpu-worker bend URL"></span>
<label>bend URL
<input id="bend-url" type="url" placeholder="https://your-gpu-worker.example/bend (sets (bend!-call ...) destination)">
</label>
<button id="bend-save" class="secondary">save</button>
<span id="bend-state" class="bend-state"></span>
</section>
<section class="vault-bar" id="vault-bar" hidden>
<span class="vault-icon" title="local vault">🔒</span>
<span id="vault-state" class="vault-state">locked</span>

View file

@ -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];
}

View file

@ -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);

View file

@ -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 });

View file

@ -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; },
};
}

Binary file not shown.

View file

@ -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];
}

View file

@ -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);

View file

@ -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 });