Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
38 changes: 37 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

```
Expand Down Expand Up @@ -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
Expand Down
61 changes: 61 additions & 0 deletions build-wasm.sh
Original file line number Diff line number Diff line change
@@ -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)."
82 changes: 79 additions & 3 deletions grove.hml
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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
// =============================================================================
Expand Down Expand Up @@ -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();
Expand All @@ -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) {
Expand Down
Loading
Loading