From c92184ec2a8d5919c11ca2217942bb4f715e4503 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 14 Jun 2026 11:55:46 +0000 Subject: [PATCH] Add in-browser execution mode via Hemlock WASM Adds a Server/Browser toggle to the playground. In Browser mode, code runs entirely client-side through the Hemlock interpreter compiled to WebAssembly (no Grove server, no network round-trip). - playground.html: execution-mode toggle (persisted in localStorage), lazy load + init of the WASM interpreter, stdout/stderr capture, and graceful fallback when artifacts are missing. Check/Format/stdin (server-backed) are disabled in Browser mode. Supports a ?wasm=URL override for CDN hosting. - wasm/: vendored JS wrapper (hemlock-api.js, pre.js) plus the generated interpreter artifacts (hemlock.js, hemlock.wasm) built from hemlang/hemlock. - build-wasm.sh: regenerates the artifacts via 'make wasm-interpreter'. - grove.hml: serve /wasm/* static assets, streaming the binary .wasm byte-for-byte so it isn't corrupted by string concatenation. - Docs: README, wasm/README, and CLAUDE.md updated. Frontend verified end-to-end in a headless browser (run, error output, mode persistence, toggle re-enabling server controls). --- CLAUDE.md | 7 + README.md | 38 +- build-wasm.sh | 61 + grove.hml | 82 +- playground.html | 225 +- wasm/README.md | 46 + wasm/hemlock-api.js | 466 ++++ wasm/hemlock.js | 6034 +++++++++++++++++++++++++++++++++++++++++++ wasm/hemlock.wasm | Bin 0 -> 2875550 bytes wasm/pre.js | 7 + 10 files changed, 6961 insertions(+), 5 deletions(-) create mode 100755 build-wasm.sh create mode 100644 wasm/README.md create mode 100644 wasm/hemlock-api.js create mode 100644 wasm/hemlock.js create mode 100755 wasm/hemlock.wasm create mode 100644 wasm/pre.js diff --git a/CLAUDE.md b/CLAUDE.md index e4b09b6..210a9f9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -161,6 +161,13 @@ hemlock --version - [ ] Wire up monaco-languageclient - [ ] Verify diagnostics, hover, completion +### Phase 3.5: Browser Execution (WASM) ✓ (Complete) +- [x] Toolbar **☁ Server / ⬡ Browser** execution-mode toggle (persisted in localStorage) +- [x] Load the Hemlock interpreter compiled to WebAssembly (`wasm/hemlock.js` + `wasm/hemlock.wasm`) +- [x] Run code client-side via `Hemlock.init()` + `hemlock.eval()` with captured stdout/stderr +- [x] `build-wasm.sh` to (re)generate artifacts from `hemlang/hemlock` (`make wasm-interpreter`) +- [x] Graceful fallback message when artifacts are missing; `?wasm=URL` override for CDN hosting + ### Phase 4: Polish - [ ] Example snippets dropdown improvements - [ ] UI/UX improvements diff --git a/README.md b/README.md index a11ccd7..66f6647 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,39 @@ hemlock grove.hml - **Code Editor** - Syntax-highlighted textarea with tab support - **Code Execution** - Run Hemlock code in a sandboxed environment +- **Two execution modes** - Run on the Grove **server**, or fully in your + **browser** via the Hemlock interpreter compiled to WebAssembly (no server, + no network round-trip) - **Example Programs** - Pre-built examples (Hello World, Fibonacci, FizzBuzz, Async, JSON) - **Real-time Feedback** - Execution time, exit codes, and error messages +## Execution Modes + +The toolbar has a **☁ Server / ⬡ Browser** toggle: + +- **☁ Server** (default) — code is sent to the Grove server's `POST /execute` + endpoint and run with `hemlock --sandbox`. Supports stdin, `✓ Check`, and + `{ } Format` (all server-backed). +- **⬡ Browser** — code runs entirely client-side in the Hemlock WebAssembly + interpreter. No server is required, so `Check`/`Format`/stdin (which rely on + the server) are disabled in this mode. The chosen mode is remembered across + reloads. + +Browser mode needs two build artifacts, `wasm/hemlock.js` and +`wasm/hemlock.wasm`. They are produced from the [hemlang/hemlock](https://github.com/hemlang/hemlock) +source tree. Regenerate them with: + +```bash +./build-wasm.sh +``` + +This requires the [Emscripten SDK](https://emscripten.org/docs/getting_started/downloads.html) +(`emcc` on your `PATH`); it clones hemlock if needed, runs +`make wasm-interpreter`, and copies the artifacts into `wasm/`. To load the +WASM from a different location (e.g. a CDN), open the playground with +`?wasm=https://example.com/path/` — the playground will fetch +`hemlock.js`/`hemlock.wasm` from there. + ## Architecture ``` @@ -117,7 +147,13 @@ hemlock test/test_examples.hml ``` playground/ ├── grove.hml # Backend HTTP server -├── playground.html # Frontend IDE +├── playground.html # Frontend IDE (server + browser/WASM modes) +├── build-wasm.sh # Builds the WASM interpreter artifacts +├── wasm/ # Browser execution mode +│ ├── hemlock-api.js # JS wrapper around the WASM interpreter (committed) +│ ├── pre.js # Emscripten pre-init shim (committed) +│ ├── hemlock.js # Emscripten loader (generated by build-wasm.sh) +│ └── hemlock.wasm # Compiled interpreter (generated by build-wasm.sh) ├── examples/ # Example programs │ ├── hello.hml │ ├── fibonacci.hml diff --git a/build-wasm.sh b/build-wasm.sh new file mode 100755 index 0000000..e57c078 --- /dev/null +++ b/build-wasm.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# +# build-wasm.sh — build the Hemlock WASM interpreter for browser execution. +# +# The playground's "Browser" execution mode runs the Hemlock interpreter +# entirely client-side via WebAssembly. That needs two build artifacts: +# +# wasm/hemlock.js Emscripten JS loader glue +# wasm/hemlock.wasm the interpreter compiled to WebAssembly +# +# These are generated from the hemlang/hemlock source tree (target +# `make wasm-interpreter`) and copied here. The small JS wrapper +# (wasm/hemlock-api.js) and pre-init shim (wasm/pre.js) are committed to +# this repo; the .js/.wasm artifacts are regenerated by this script. +# +# Requirements: +# - Emscripten SDK on PATH (emcc). See https://emscripten.org/docs/getting_started/downloads.html +# - A checkout of hemlang/hemlock (cloned automatically if missing) +# +# Usage: +# ./build-wasm.sh # clone/build hemlock, copy artifacts +# HEMLOCK_DIR=/path/to/hemlock ./build-wasm.sh # use an existing checkout +# +set -euo pipefail + +PLAYGROUND_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +HEMLOCK_DIR="${HEMLOCK_DIR:-$PLAYGROUND_DIR/../hemlock}" +HEMLOCK_REPO="${HEMLOCK_REPO:-https://github.com/hemlang/hemlock.git}" + +echo "==> Hemlock WASM build" +echo " playground: $PLAYGROUND_DIR" +echo " hemlock: $HEMLOCK_DIR" + +if ! command -v emcc >/dev/null 2>&1; then + echo "ERROR: emcc (Emscripten) not found on PATH." >&2 + echo " Install the Emscripten SDK and 'source emsdk_env.sh', then retry." >&2 + echo " https://emscripten.org/docs/getting_started/downloads.html" >&2 + exit 1 +fi + +if [ ! -d "$HEMLOCK_DIR" ]; then + echo "==> Cloning hemlock into $HEMLOCK_DIR" + git clone --depth 1 "$HEMLOCK_REPO" "$HEMLOCK_DIR" +fi + +echo "==> Building wasm-interpreter (this can take a few minutes)" +make -C "$HEMLOCK_DIR" wasm-interpreter + +echo "==> Copying artifacts into $PLAYGROUND_DIR/wasm/" +mkdir -p "$PLAYGROUND_DIR/wasm" +cp "$HEMLOCK_DIR/wasm/hemlock.js" "$PLAYGROUND_DIR/wasm/hemlock.js" +cp "$HEMLOCK_DIR/wasm/hemlock.wasm" "$PLAYGROUND_DIR/wasm/hemlock.wasm" +# Refresh the wrapper too, in case the upstream API changed. +cp "$HEMLOCK_DIR/wasm/hemlock-api.js" "$PLAYGROUND_DIR/wasm/hemlock-api.js" +cp "$HEMLOCK_DIR/wasm/pre.js" "$PLAYGROUND_DIR/wasm/pre.js" + +echo "==> Done. Artifacts:" +ls -lh "$PLAYGROUND_DIR/wasm/" +echo +echo "Open playground.html and switch the toolbar toggle to 'Browser' to run" +echo "Hemlock entirely in the browser (no Grove server required)." diff --git a/grove.hml b/grove.hml index c056b8e..c77853a 100644 --- a/grove.hml +++ b/grove.hml @@ -11,7 +11,7 @@ import { TcpListener } from "@stdlib/net"; import { stringify, parse } from "@stdlib/json"; import { time_ms } from "@stdlib/time"; -import { write_file, exists, remove_file, read_file, cwd } from "@stdlib/fs"; +import { write_file, exists, remove_file, read_file, cwd, open, file_stat } from "@stdlib/fs"; import { run_capture } from "@stdlib/shell"; import { getenv } from "@stdlib/env"; @@ -917,6 +917,72 @@ fn handle_not_found(path) { }); } +// Content type for a static asset, keyed off its file extension. +fn static_content_type(rel) { + if (rel.length >= 5 && rel.substr(rel.length - 5, 5) == ".wasm") { + return "application/wasm"; + } + if (rel.length >= 3 && rel.substr(rel.length - 3, 3) == ".js") { + return "text/javascript; charset=utf-8"; + } + if (rel.length >= 5 && rel.substr(rel.length - 5, 5) == ".html") { + return "text/html; charset=utf-8"; + } + if (rel.length >= 5 && rel.substr(rel.length - 5, 5) == ".json") { + return "application/json"; + } + return "application/octet-stream"; +} + +// Build just the HTTP response headers (no body). Used for binary static +// assets, where the body is a byte buffer written to the socket separately +// so it never passes through string concatenation (which would corrupt it). +fn build_headers(status, status_text, content_type, content_length) { + let h = `HTTP/1.1 ${status} ${status_text}\r\n`; + h = h + `Content-Type: ${content_type}\r\n`; + h = h + `Content-Length: ${content_length}\r\n`; + h = h + `Access-Control-Allow-Origin: ${CONFIG["cors_origin"]}\r\n`; + h = h + "Cache-Control: public, max-age=3600\r\n"; + h = h + "Connection: close\r\n"; + h = h + "\r\n"; + return h; +} + +// Serve a file from the playground's wasm/ directory (browser execution mode). +// Writes directly to the socket so the binary hemlock.wasm is sent byte-for-byte. +// Returns true if it handled the request. +fn handle_static(stream, url_path) { + // Only ever serve files under wasm/, and never allow path traversal. + if (url_path.find("..") != -1) { + stream.write(json_response(403, { error: "Forbidden", path: url_path })); + return true; + } + // Strip the leading "/" to get a path relative to the playground root. + let rel = url_path.substr(1, url_path.length - 1); + if (rel.length < 5 || rel.substr(0, 5) != "wasm/") { + return false; // not a static asset route; let the caller 404 + } + + let full = `${cwd()}/${rel}`; + if (!exists(full)) { + stream.write(json_response(404, { error: "Not found", path: url_path })); + return true; + } + + let info = file_stat(full); + let size = info["size"]; + let ctype = static_content_type(rel); + + // Read the file as bytes and stream headers + body separately. + let f = open(full, "r"); + let body = f.read_bytes(size); + f.close(); + + stream.write(build_headers(200, "OK", ctype, size)); + stream.write(body); + return true; +} + // ============================================================================= // Connection Handler // ============================================================================= @@ -1047,6 +1113,7 @@ fn handle_connection(stream) { // Route request let response = ""; let method = req["method"]; + let handled = false; // set when a handler writes to the stream itself if (method == "OPTIONS") { response = handle_options(); @@ -1062,12 +1129,21 @@ fn handle_connection(stream) { response = handle_check(req["body"]); } else if (path == "/format" && method == "POST") { response = handle_format(req["body"]); + } else if (path.length >= 6 && path.substr(0, 6) == "/wasm/" && method == "GET") { + // Static assets for browser (WASM) execution mode. handle_static + // streams the response (including the binary .wasm) directly. + handled = handle_static(stream, path); + if (!handled) { + response = handle_not_found(path); + } } else { response = handle_not_found(path); } - // Send response - stream.write(response); + // Send response (unless the handler already wrote to the stream) + if (!handled) { + stream.write(response); + } stream.close(); } catch (e) { diff --git a/playground.html b/playground.html index 9bedc07..4671634 100644 --- a/playground.html +++ b/playground.html @@ -108,6 +108,36 @@ cursor: not-allowed; } + /* Execution-mode toggle (Server vs Browser/WASM) */ + .mode-toggle { + display: inline-flex; + border: 1px solid var(--border); + border-radius: 6px; + overflow: hidden; + } + + .mode-toggle button { + border: none; + border-radius: 0; + background: var(--bg-lighter); + padding: 8px 12px; + font-size: 0.8rem; + gap: 4px; + } + + .mode-toggle button + button { + border-left: 1px solid var(--border); + } + + .mode-toggle button.active { + background: var(--accent); + color: var(--bg-dark); + } + + .mode-toggle button.active:hover { + background: #8fb0fa; + } + .main-container { flex: 1; display: flex; @@ -438,6 +468,10 @@ +
+ + +
@@ -876,10 +910,91 @@ const stdinInput = document.getElementById('stdin-input'); const inputSection = document.getElementById('input-section'); const inputHeader = document.getElementById('input-header'); + const modeToggle = document.getElementById('mode-toggle'); // State let isRunning = false; + // ==================================================================== + // Execution mode: 'server' (Grove HTTP API) or 'wasm' (in-browser) + // ==================================================================== + // Where to load the compiled interpreter from. Override with ?wasm=URL + // (e.g. a CDN) if you don't want to serve the artifacts locally. + const WASM_BASE = (new URLSearchParams(location.search).get('wasm') || 'wasm/').replace(/\/?$/, '/'); + + let executionMode = localStorage.getItem('hemlock-exec-mode') || 'server'; + + // Lazy-initialized WASM interpreter handle. Stays null until the user + // first switches to (or runs in) browser mode. + const wasm = { + loading: null, // Promise while the module is being fetched/initialized + hemlock: null, // Hemlock API instance once ready + out: [], // Captured stdout/stderr lines for the current run + }; + + // Inject a diff --git a/wasm/README.md b/wasm/README.md new file mode 100644 index 0000000..d36dbef --- /dev/null +++ b/wasm/README.md @@ -0,0 +1,46 @@ +# Browser execution mode (Hemlock WASM) + +This directory holds the assets that let `playground.html` run Hemlock code +entirely in the browser, with no Grove server. When the toolbar toggle is set +to **⬡ Browser**, the playground loads these files and executes code through the +Hemlock interpreter compiled to WebAssembly. + +## Files + +| File | Source | Committed? | +|------|--------|------------| +| `hemlock-api.js` | A clean JS wrapper around the WASM interpreter (`Hemlock.init`, `.eval`, contexts). Vendored from `hemlang/hemlock`. | ✅ yes | +| `pre.js` | Emscripten pre-init shim (sets up the `Module` global). Vendored from `hemlang/hemlock`. | ✅ yes | +| `hemlock.js` | Emscripten loader glue. **Generated** by `make wasm-interpreter`. | ✅ committed for convenience | +| `hemlock.wasm` | The interpreter compiled to WebAssembly (~3 MB). **Generated**. | ✅ committed for convenience | + +## Regenerating the artifacts + +From the playground root: + +```bash +./build-wasm.sh +``` + +This needs the [Emscripten SDK](https://emscripten.org/docs/getting_started/downloads.html) +(`emcc` on `PATH`). It will clone `hemlang/hemlock` if it isn't already next to +this repo, run `make wasm-interpreter`, and copy the four files above into here. + +## How the playground uses these + +`playground.html` configures an Emscripten `Module` with: + +- `locateFile` pointing back at this directory (the HTML lives one level up, so + Emscripten would otherwise look for `hemlock.wasm` at the site root), +- `print` / `printErr` handlers that capture interpreter output, and +- initialization deferred to `onRuntimeInitialized` so the JS API only binds + once the wasm exports are live. + +It then calls `Hemlock.init({ Module })` and runs source via `hemlock.eval(...)`. + +## Limitations in the browser + +The WASM interpreter is single-threaded and sandboxed by the browser, so some +features are unavailable: FFI, OpenSSL crypto, `fork`/`exec`, and threading +(`spawn`/channels). `Check` and `Format` in the playground are server-backed and +are disabled in browser mode. diff --git a/wasm/hemlock-api.js b/wasm/hemlock-api.js new file mode 100644 index 0000000..eb33b5b --- /dev/null +++ b/wasm/hemlock-api.js @@ -0,0 +1,466 @@ +/** + * Hemlock WASM JavaScript API + * + * A clean JavaScript wrapper around the Hemlock WebAssembly interpreter. + * Works in both browser and Node.js environments. + * + * Usage (browser): + * + * + * + * + * Usage (Node.js): + * const { Hemlock } = require('./hemlock-api.js'); + * Hemlock.init('./hemlock.js').then(function(hemlock) { + * hemlock.eval('print("hello");'); + * }); + * + * Three API layers: + * 1. hemlock.eval(source) - Stateless one-shot execution + * 2. hemlock.createContext() - Persistent context (variables survive across evals) + * 3. hemlock.compile(source) - Cached script (parse once, run many) + */ + +(function(root) { + 'use strict'; + + /* ==================================================================== + * HemlockContext - Persistent interpreter context + * ==================================================================== */ + + /** + * A persistent Hemlock interpreter context. + * Variables and functions defined in one eval() call are visible in later calls. + * + * Do not construct directly - use hemlock.createContext(). + * + * @param {number} handle - Internal context handle + * @param {object} fns - Wrapped C functions + */ + function HemlockContext(handle, fns) { + this._handle = handle; + this._fns = fns; + this._destroyed = false; + } + + /** + * Execute Hemlock source code in this context. + * Variables defined here persist for future eval() calls. + * + * @param {string} source - Hemlock source code + * @returns {{ ok: boolean, error: string|null }} + * ok=true on success, ok=false with error message on failure + */ + HemlockContext.prototype.eval = function(source) { + if (this._destroyed) { + return { ok: false, error: 'Context has been destroyed' }; + } + var rc = this._fns.ctxEval(this._handle, source); + if (rc === 0) { + return { ok: true, error: null }; + } + var msg = this._fns.ctxLastError(this._handle); + if (rc === 1) { + return { ok: false, error: msg || 'Parse error' }; + } + if (rc === 2) { + return { ok: false, error: msg || 'Runtime error' }; + } + return { ok: false, error: msg || 'Invalid context handle' }; + }; + + /** + * Read a variable from this context, returned as a parsed JavaScript value. + * + * @param {string} name - Variable name + * @returns {*} The variable's value (parsed from JSON), or undefined if not found + */ + HemlockContext.prototype.get = function(name) { + if (this._destroyed) { return undefined; } + var json = this._fns.ctxGet(this._handle, name); + if (json === null || json === undefined || json === '') { + return undefined; + } + try { + return JSON.parse(json); + } catch (e) { + // Return raw string if not valid JSON (e.g. function values) + return json; + } + }; + + /** + * Read a variable from this context as a raw JSON string. + * + * @param {string} name - Variable name + * @returns {string|null} JSON string, or null if not found + */ + HemlockContext.prototype.getJSON = function(name) { + if (this._destroyed) { return null; } + var json = this._fns.ctxGet(this._handle, name); + if (json === null || json === undefined || json === '') { + return null; + } + return json; + }; + + /** + * Inject a value into this context. The value is serialized to JSON. + * + * @param {string} name - Variable name + * @param {*} value - Value to inject (must be JSON-serializable) + * @returns {boolean} true on success, false on error + */ + HemlockContext.prototype.set = function(name, value) { + if (this._destroyed) { return false; } + var json = JSON.stringify(value); + if (json === undefined) { return false; } + var rc = this._fns.ctxSet(this._handle, name, json); + return rc === 0; + }; + + /** + * Inject a raw JSON string into this context. + * + * @param {string} name - Variable name + * @param {string} json - JSON string representing the value + * @returns {boolean} true on success, false on error + */ + HemlockContext.prototype.setJSON = function(name, json) { + if (this._destroyed) { return false; } + var rc = this._fns.ctxSet(this._handle, name, json); + return rc === 0; + }; + + /** + * Get the error message from the last failed eval() or run(). + * + * @returns {string|null} Error message, or null if the last call succeeded + */ + HemlockContext.prototype.lastError = function() { + if (this._destroyed) { return null; } + var err = this._fns.ctxLastError(this._handle); + return (err === null || err === undefined || err === '') ? null : err; + }; + + /** + * Destroy this context and free all associated memory. + * After calling destroy(), all other methods become no-ops. + */ + HemlockContext.prototype.destroy = function() { + if (this._destroyed) { return; } + this._fns.ctxDestroy(this._handle); + this._destroyed = true; + this._handle = 0; + }; + + /* ==================================================================== + * HemlockScript - Cached (pre-compiled) script + * ==================================================================== */ + + /** + * A pre-compiled Hemlock script. Parse once, execute many times. + * Ideal for hot event handlers, animation callbacks, or per-frame logic. + * + * Do not construct directly - use hemlock.compile(). + * + * @param {number} handle - Internal script handle + * @param {object} fns - Wrapped C functions + */ + function HemlockScript(handle, fns) { + this._handle = handle; + this._fns = fns; + this._freed = false; + } + + /** + * Execute this cached script in a persistent context. + * The script's AST is reused without re-parsing. + * + * @param {HemlockContext} context - The context to execute in + * @returns {{ ok: boolean, error: string|null }} + */ + HemlockScript.prototype.run = function(context) { + if (this._freed) { + return { ok: false, error: 'Script has been freed' }; + } + if (context._destroyed) { + return { ok: false, error: 'Context has been destroyed' }; + } + var rc = this._fns.runScript(context._handle, this._handle); + if (rc === 0) { + return { ok: true, error: null }; + } + var msg = context.lastError(); + return { ok: false, error: msg || 'Script execution error (code ' + rc + ')' }; + }; + + /** + * Free this cached script and release its AST. + * + * WARNING: Ensure no context still holds function values that reference + * this script's AST. Destroy or reset relevant contexts first. + */ + HemlockScript.prototype.free = function() { + if (this._freed) { return; } + this._fns.freeScript(this._handle); + this._freed = true; + this._handle = 0; + }; + + /* ==================================================================== + * Hemlock - Main API entry point + * ==================================================================== */ + + /** + * Main Hemlock WASM API. + * Use Hemlock.init() to create an instance. + * + * @param {object} module - Emscripten Module object + * @param {object} fns - Wrapped C functions + */ + function Hemlock(module, fns) { + this._module = module; + this._fns = fns; + + /** @type {string} Hemlock version string */ + this.version = fns.version(); + } + + /** + * Initialize the Hemlock WASM module and return a ready-to-use API instance. + * + * @param {string|object} [options] - Path to hemlock.js (Node.js), or options object + * @param {string} [options.wasmUrl] - URL/path to hemlock.js + * @param {function} [options.print] - stdout handler (default: console.log) + * @param {function} [options.printErr] - stderr handler (default: console.error) + * @param {object} [options.Module] - Pre-configured Emscripten Module (browser) + * @returns {Promise} Initialized Hemlock API instance + * + * @example + * // Browser (hemlock.js already loaded via