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