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:
parent
51b449a83d
commit
7e32eefd6b
27 changed files with 612 additions and 27 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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; },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ─────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue