diff --git a/experiments/001_hello_world/lib b/experiments/001_hello_world/lib new file mode 120000 index 0000000..565e254 --- /dev/null +++ b/experiments/001_hello_world/lib @@ -0,0 +1 @@ +../shared/lib \ No newline at end of file diff --git a/experiments/002_chromium_sandbox/lib b/experiments/002_chromium_sandbox/lib index 207813e..565e254 120000 --- a/experiments/002_chromium_sandbox/lib +++ b/experiments/002_chromium_sandbox/lib @@ -1 +1 @@ -../001_hello_world/lib \ No newline at end of file +../shared/lib \ No newline at end of file diff --git a/experiments/003_wasm_compile/.gitignore b/experiments/003_wasm_compile/.gitignore index 43dc8bc..4c5a0db 100644 --- a/experiments/003_wasm_compile/.gitignore +++ b/experiments/003_wasm_compile/.gitignore @@ -10,7 +10,7 @@ python-raw/wit_world/ python-raw/componentize_py_types.py python-raw/componentize_py_async_support/ python-raw/poll_loop.py -rust/target/ +rust-hello/target/ as-hello/node_modules/ as-hello/build/ __pycache__/ diff --git a/experiments/003_wasm_compile/Makefile b/experiments/003_wasm_compile/Makefile index ef4ac17..0ace639 100644 --- a/experiments/003_wasm_compile/Makefile +++ b/experiments/003_wasm_compile/Makefile @@ -1,12 +1,14 @@ -.PHONY: deps build test bench bench-quick clean help +.PHONY: deps build build-js build-py build-rust build-as test bench bench-quick clean help CONTAINER_CMD ?= $(shell command -v podman 2>/dev/null || echo docker) -VENV := .venv -COMPONENTIZE := $(VENV)/bin/componentize-py +GIT_ROOT := $(shell git rev-parse --show-toplevel) +VENV := $(GIT_ROOT)/.venv +ACTIVATE := . $(VENV)/bin/activate && +BUILD_DIR := build $(VENV)/bin/activate: requirements.txt - python3 -m venv $(VENV) - $(VENV)/bin/pip install --quiet -r requirements.txt + uv venv --clear $(VENV) + uv pip install --quiet -r requirements.txt @touch $(VENV)/bin/activate deps: $(VENV)/bin/activate ## Install toolchain dependencies (spin, componentize-py, hey) @@ -16,34 +18,52 @@ deps: $(VENV)/bin/activate ## Install toolchain dependencies (spin, componentiz brew install hey @echo "✓ deps ready (componentize-py in $(VENV); JS deps fetched at build time by spin build)" -build: $(VENV)/bin/activate ## Compile all sources to .wasm +build: build-js build-py build-rust build-as ## Compile all sources to .wasm → build/ + @echo "✓ All .wasm artifacts in $(BUILD_DIR)/" + +build-js: ## Build JS → .wasm (Spin) @echo "→ Building JS (Spin)..." cd js-spin && spin build + @mkdir -p $(BUILD_DIR) + cp js-spin/dist/hello-js.wasm $(BUILD_DIR)/hello-js-spin.wasm + +build-py: $(VENV)/bin/activate ## Build Python → .wasm (componentize-py + Spin) + @mkdir -p $(BUILD_DIR) @echo "→ Building Python/raw (componentize-py)..." - cd python-raw && ../$(COMPONENTIZE) -d wit -w proxy componentize app -o hello-py-raw.wasm + cd python-raw && VIRTUAL_ENV= $(VENV)/bin/componentize-py -d wit -w example:hello/proxy --all-features componentize app -o hello-py-raw.wasm + cp python-raw/hello-py-raw.wasm $(BUILD_DIR)/hello-py-raw.wasm @echo "→ Building Python (Spin)..." - cd python-spin && spin build + cd python-spin && $(ACTIVATE) spin build + cp python-spin/app.wasm $(BUILD_DIR)/hello-py-spin.wasm + +build-rust: ## Build Rust → .wasm (cargo wasm32-wasip2) @echo "→ Building Rust baseline..." - cd rust && cargo build --target wasm32-wasip2 --release --quiet + cd rust-hello && cargo build --target wasm32-wasip2 --release --quiet + @mkdir -p $(BUILD_DIR) + cp rust-hello/target/wasm32-wasip2/release/leg3.wasm $(BUILD_DIR)/hello-rust.wasm + +build-as: ## Build AssemblyScript → .wasm (asc + wasm-tools) @echo "→ Building AssemblyScript (asc + wasm-tools)..." - cd as-hello && npm ci --silent 2>/dev/null && ./build.sh - @echo "✓ All .wasm artifacts built" + cd as-hello && npm ci --silent && ./build.sh + @mkdir -p $(BUILD_DIR) + cp as-hello/build/hello-as.wasm $(BUILD_DIR)/hello-as.wasm test: ## Run BATS unit tests bats tests/ -bench: ## Run full 6-leg benchmark +bench: ## Run full 7-leg benchmark ./benchmark.sh bench-quick: ## Quick benchmark (10 requests) HEY_N=10 ./benchmark.sh clean: ## Remove build artifacts + rm -rf $(BUILD_DIR) rm -rf $(VENV) rm -rf js-spin/node_modules js-spin/build js-spin/dist rm -f python-spin/app.wasm rm -f python-raw/hello-py-raw.wasm - rm -rf rust/target + rm -rf rust-hello/target rm -rf as-hello/node_modules as-hello/build $(CONTAINER_CMD) rmi -f hello-js-spin hello-py-spin 2>/dev/null || true diff --git a/experiments/003_wasm_compile/benchmark.sh b/experiments/003_wasm_compile/benchmark.sh index 19a5b22..9534bda 100755 --- a/experiments/003_wasm_compile/benchmark.sh +++ b/experiments/003_wasm_compile/benchmark.sh @@ -225,7 +225,7 @@ run_leg3() { require_port_free 5035 "Leg 3" command -v cargo &>/dev/null || fail "cargo not found" - pushd "$SCRIPT_DIR/rust" >/dev/null + pushd "$SCRIPT_DIR/rust-hello" >/dev/null APP_3=$(human_size src/lib.rs) BUILD_3=$(timed_build "cargo wasm32-wasip2" \ cargo build --target wasm32-wasip2 --release --quiet 2>/dev/null) diff --git a/experiments/003_wasm_compile/lib b/experiments/003_wasm_compile/lib index 207813e..565e254 120000 --- a/experiments/003_wasm_compile/lib +++ b/experiments/003_wasm_compile/lib @@ -1 +1 @@ -../001_hello_world/lib \ No newline at end of file +../shared/lib \ No newline at end of file diff --git a/experiments/003_wasm_compile/python-spin/app.py b/experiments/003_wasm_compile/python-spin/app.py index 07c1cdd..a12d1a5 100644 --- a/experiments/003_wasm_compile/python-spin/app.py +++ b/experiments/003_wasm_compile/python-spin/app.py @@ -1,19 +1,19 @@ import json import time -from spin_sdk import http -from spin_sdk.http import Request, Response +from spin_sdk.http import IncomingHandler, Request, Response -def handle_request(request: Request) -> Response: - body = json.dumps( - { - "message": "Hello World", - "timestamp": time.time(), - } - ) - return Response( - 200, - {"content-type": "application/json"}, - bytes(body, "utf-8"), - ) +class IncomingHandler(IncomingHandler): + def handle_request(self, request: Request) -> Response: + body = json.dumps( + { + "message": "Hello World", + "timestamp": time.time(), + } + ) + return Response( + 200, + {"content-type": "application/json"}, + bytes(body, "utf-8"), + ) diff --git a/experiments/003_wasm_compile/python-spin/spin.toml b/experiments/003_wasm_compile/python-spin/spin.toml index 22827aa..b236cd4 100644 --- a/experiments/003_wasm_compile/python-spin/spin.toml +++ b/experiments/003_wasm_compile/python-spin/spin.toml @@ -14,5 +14,5 @@ source = "app.wasm" allowed_outbound_hosts = [] [component.hello-py.build] -command = "../.venv/bin/componentize-py -d wit -w proxy componentize app -o app.wasm" +command = "componentize-py -w spin-http componentize app -o app.wasm" watch = ["app.py"] diff --git a/experiments/003_wasm_compile/requirements.txt b/experiments/003_wasm_compile/requirements.txt index 8ea3771..438c236 100644 --- a/experiments/003_wasm_compile/requirements.txt +++ b/experiments/003_wasm_compile/requirements.txt @@ -1 +1,2 @@ -componentize-py +componentize-py>=0.17,<0.18 +spin-sdk>=3.4,<4 diff --git a/experiments/003_wasm_compile/rust b/experiments/003_wasm_compile/rust deleted file mode 120000 index f261943..0000000 --- a/experiments/003_wasm_compile/rust +++ /dev/null @@ -1 +0,0 @@ -../001_hello_world/leg3_wasmtime \ No newline at end of file diff --git a/experiments/003_wasm_compile/rust-hello/Cargo.lock b/experiments/003_wasm_compile/rust-hello/Cargo.lock new file mode 100644 index 0000000..bd8eb65 --- /dev/null +++ b/experiments/003_wasm_compile/rust-hello/Cargo.lock @@ -0,0 +1,43 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "leg3" +version = "0.1.0" +dependencies = [ + "wasi", +] + +[[package]] +name = "wasi" +version = "0.14.7+wasi-0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c" +dependencies = [ + "wasip2", +] + +[[package]] +name = "wasip2" +version = "1.0.2+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "bitflags", +] diff --git a/experiments/003_wasm_compile/rust-hello/Cargo.toml b/experiments/003_wasm_compile/rust-hello/Cargo.toml new file mode 100644 index 0000000..1c0b266 --- /dev/null +++ b/experiments/003_wasm_compile/rust-hello/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "leg3" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +wasi = "0.14" diff --git a/experiments/003_wasm_compile/rust-hello/run.sh b/experiments/003_wasm_compile/rust-hello/run.sh new file mode 100755 index 0000000..61e16c7 --- /dev/null +++ b/experiments/003_wasm_compile/rust-hello/run.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +set -euo pipefail + +PORT=5003 +DIR="$(cd "$(dirname "$0")" && pwd)" + +# Ensure Rust/cargo is on PATH — respects CARGO_HOME (XDG-compliant) +export PATH="${CARGO_HOME:-${XDG_DATA_HOME:-$HOME/.local/share}/cargo}/bin:$PATH" + +command -v cargo &>/dev/null || { echo "✗ cargo not found — install rustup" >&2; exit 1; } +command -v wasmtime &>/dev/null || { echo "✗ wasmtime not found — brew install wasmtime" >&2; exit 1; } + +# Ensure wasm32-wasip2 target is available +if ! rustup target list --installed 2>/dev/null | grep -q "wasm32-wasip2"; then + echo "→ Adding wasm32-wasip2 Rust target..." + rustup target add wasm32-wasip2 +fi + +cd "$DIR" + +echo "→ Building Rust WASM component (wasm32-wasip2)..." +cargo build --target wasm32-wasip2 --release 2>&1 + +WASM=$(find target/wasm32-wasip2/release -maxdepth 1 -name "*.wasm" | head -1) +if [ -z "$WASM" ]; then + echo "✗ No .wasm file found in target/wasm32-wasip2/release/" >&2 + exit 1 +fi +echo "→ Binary: $WASM ($(du -sh "$WASM" | cut -f1))" + +echo "→ Starting wasmtime serve on port $PORT..." +wasmtime serve -S cli --addr "127.0.0.1:$PORT" "$WASM" & +WASMTIME_PID=$! + +# Wait for readiness +echo -n "→ Waiting for HTTP..." +for i in $(seq 1 30); do + if curl -sf "http://127.0.0.1:$PORT/" &>/dev/null; then + echo " ready" + curl -s "http://127.0.0.1:$PORT/" | python3 -m json.tool + wait "$WASMTIME_PID" + exit 0 + fi + sleep 0.2 +done + +echo " timeout" >&2 +kill "$WASMTIME_PID" 2>/dev/null || true +exit 1 diff --git a/experiments/003_wasm_compile/rust-hello/src/lib.rs b/experiments/003_wasm_compile/rust-hello/src/lib.rs new file mode 100644 index 0000000..3f2e2fe --- /dev/null +++ b/experiments/003_wasm_compile/rust-hello/src/lib.rs @@ -0,0 +1,33 @@ +use wasi::clocks::wall_clock; +use wasi::exports::wasi::http::incoming_handler::Guest; +use wasi::http::types::{Fields, IncomingRequest, OutgoingBody, OutgoingResponse, ResponseOutparam}; + +struct Component; + +impl Guest for Component { + fn handle(_request: IncomingRequest, response_out: ResponseOutparam) { + let now = wall_clock::now(); + let ts = now.seconds as f64 + (now.nanoseconds as f64 / 1_000_000_000.0); + let body_str = format!(r#"{{"message":"Hello World","timestamp":{ts:.6}}}"#); + + let headers = Fields::new(); + headers + .append(&"content-type".to_string(), &b"application/json".to_vec()) + .unwrap(); + + let response = OutgoingResponse::new(headers); + response.set_status_code(200).unwrap(); + + let out_body = response.body().unwrap(); + { + let stream = out_body.write().unwrap(); + stream + .blocking_write_and_flush(body_str.as_bytes()) + .unwrap(); + } + OutgoingBody::finish(out_body, None).unwrap(); + ResponseOutparam::set(response_out, Ok(response)); + } +} + +wasi::http::proxy::export!(Component with_types_in wasi); diff --git a/experiments/003_wasm_compile/tests/test_003.bats b/experiments/003_wasm_compile/tests/test_003.bats index a3ae30c..181a102 100644 --- a/experiments/003_wasm_compile/tests/test_003.bats +++ b/experiments/003_wasm_compile/tests/test_003.bats @@ -1,23 +1,31 @@ #!/usr/bin/env bats # Experiment 003 — BATS tests -# Validates that each WASM HTTP leg returns valid JSON with expected shape. +# Validates sources, build artifacts, and runtime legs. # -# Prerequisites: all legs must already be built (make build) and running -# on their designated ports. These tests are run by benchmark.sh inline; -# for standalone use start each leg manually first. +# Naming: NNN_component — phase: description +# +# Build artifacts (in build/): +# hello-js-spin.wasm — JS compiled via Spin (legs 1a, 1b) +# hello-py-raw.wasm — Python compiled via componentize-py (leg 2a) +# hello-py-spin.wasm — Python compiled via Spin (legs 2b, 2c) +# hello-rust.wasm — Rust compiled via cargo (leg 3) +# hello-as.wasm — AssemblyScript via asc + wasm-tools (leg 4) SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" +GIT_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)" +PYTHON="uv run --directory $GIT_ROOT python" +BUILD_DIR="$SCRIPT_DIR/build" + +# ── Helpers ─────────────────────────────────────────────────────────────────── -# ── Helper: fetch JSON from a running leg ───────────────────────────────────── json_get() { local port=$1 path=${2:-/} curl -sf "http://127.0.0.1:${port}${path}" } -# ── Source validation helper ────────────────────────────────────────────────── valid_hello_json() { local json=$1 - echo "$json" | python3 -c " + echo "$json" | $PYTHON -c " import sys, json d = json.load(sys.stdin) assert 'message' in d, 'missing message field' @@ -28,90 +36,109 @@ print('ok') " } -# ── Python source file smoke tests (no runtime required) ───────────────────── - -@test "python-spin/app.py is syntactically valid Python" { - python3 -m py_compile "$SCRIPT_DIR/python-spin/app.py" -} - -@test "python-raw/app.py is syntactically valid Python" { - python3 -m py_compile "$SCRIPT_DIR/python-raw/app.py" +skip_if_no_port() { + local port=$1 + if ! curl -sf --max-time 1 "http://127.0.0.1:${port}/" >/dev/null 2>&1; then + skip "port ${port} not listening — start the leg first" + fi } -# ── spin.toml presence ─────────────────────────────────────────────────────── +# ── js-spin ─────────────────────────────────────────────────────────────────── -@test "js-spin/spin.toml exists" { +@test "001_js-spin — source: spin.toml exists" { [ -f "$SCRIPT_DIR/js-spin/spin.toml" ] } -@test "python-spin/spin.toml exists" { - [ -f "$SCRIPT_DIR/python-spin/spin.toml" ] +@test "002_js-spin — build: hello-js-spin.wasm exists" { + [ -f "$BUILD_DIR/hello-js-spin.wasm" ] } -# ── WIT interface ───────────────────────────────────────────────────────────── +@test "003_js-spin — leg 1a: native (port 5030) returns Hello World JSON" { + skip_if_no_port 5030 + result=$(json_get 5030) + [ "$(valid_hello_json "$result")" = "ok" ] +} -@test "python-raw/wit/proxy.wit exists" { - [ -f "$SCRIPT_DIR/python-raw/wit/proxy.wit" ] +@test "004_js-spin — leg 1b: podman (port 5031) returns Hello World JSON" { + skip_if_no_port 5031 + result=$(json_get 5031) + [ "$(valid_hello_json "$result")" = "ok" ] } -# ── AssemblyScript source ──────────────────────────────────────────────────── +# ── py-raw ──────────────────────────────────────────────────────────────────── -@test "as-hello/assembly/index.ts exists" { - [ -f "$SCRIPT_DIR/as-hello/assembly/index.ts" ] +@test "005_py-raw — source: app.py is valid Python" { + $PYTHON -m py_compile "$SCRIPT_DIR/python-raw/app.py" } -@test "as-hello/build.sh is executable" { - [ -x "$SCRIPT_DIR/as-hello/build.sh" ] +@test "006_py-raw — source: wit/proxy.wit exists" { + [ -f "$SCRIPT_DIR/python-raw/wit/proxy.wit" ] } -# ── Runtime leg tests (skipped when ports not listening) ───────────────────── +@test "007_py-raw — build: hello-py-raw.wasm exists" { + [ -f "$BUILD_DIR/hello-py-raw.wasm" ] +} -@test "leg 1a JS/Spin native — returns Hello World JSON" { - skip_if_no_port 5030 - result=$(json_get 5030) +@test "008_py-raw — leg 2a: wasmtime (port 5032) returns Hello World JSON" { + skip_if_no_port 5032 + result=$(json_get 5032) [ "$(valid_hello_json "$result")" = "ok" ] } -@test "leg 1b JS/Spin podman — returns Hello World JSON" { - skip_if_no_port 5031 - result=$(json_get 5031) - [ "$(valid_hello_json "$result")" = "ok" ] +# ── py-spin ─────────────────────────────────────────────────────────────────── + +@test "009_py-spin — source: app.py is valid Python" { + $PYTHON -m py_compile "$SCRIPT_DIR/python-spin/app.py" } -@test "leg 2a Python/raw wasmtime — returns Hello World JSON" { - skip_if_no_port 5032 - result=$(json_get 5032) - [ "$(valid_hello_json "$result")" = "ok" ] +@test "010_py-spin — source: spin.toml exists" { + [ -f "$SCRIPT_DIR/python-spin/spin.toml" ] } -@test "leg 2b Python/Spin native — returns Hello World JSON" { +@test "011_py-spin — build: hello-py-spin.wasm exists" { + [ -f "$BUILD_DIR/hello-py-spin.wasm" ] +} + +@test "012_py-spin — leg 2b: native (port 5033) returns Hello World JSON" { skip_if_no_port 5033 result=$(json_get 5033) [ "$(valid_hello_json "$result")" = "ok" ] } -@test "leg 2c Python/Spin podman — returns Hello World JSON" { +@test "013_py-spin — leg 2c: podman (port 5034) returns Hello World JSON" { skip_if_no_port 5034 result=$(json_get 5034) [ "$(valid_hello_json "$result")" = "ok" ] } -@test "leg 3 Rust/wasmtime baseline — returns Hello World JSON" { +# ── rust ────────────────────────────────────────────────────────────────────── + +@test "014_rust — build: hello-rust.wasm exists" { + [ -f "$BUILD_DIR/hello-rust.wasm" ] +} + +@test "015_rust — leg 3: wasmtime (port 5035) returns Hello World JSON" { skip_if_no_port 5035 result=$(json_get 5035) [ "$(valid_hello_json "$result")" = "ok" ] } -@test "leg 4 AS/wasmtime — returns Hello World JSON" { +# ── as-hello ────────────────────────────────────────────────────────────────── + +@test "016_as — source: assembly/index.ts exists" { + [ -f "$SCRIPT_DIR/as-hello/assembly/index.ts" ] +} + +@test "017_as — source: build.sh is executable" { + [ -x "$SCRIPT_DIR/as-hello/build.sh" ] +} + +@test "018_as — build: hello-as.wasm exists" { + [ -f "$BUILD_DIR/hello-as.wasm" ] +} + +@test "019_as — leg 4: wasmtime (port 5036) returns Hello World JSON" { skip_if_no_port 5036 result=$(json_get 5036) [ "$(valid_hello_json "$result")" = "ok" ] } - -# ── Helper loaded after test definitions ────────────────────────────────────── -skip_if_no_port() { - local port=$1 - if ! curl -sf --max-time 1 "http://127.0.0.1:${port}/" >/dev/null 2>&1; then - skip "port ${port} not listening — start the leg first" - fi -} diff --git a/experiments/001_hello_world/lib/bench.sh b/experiments/shared/lib/bench.sh similarity index 100% rename from experiments/001_hello_world/lib/bench.sh rename to experiments/shared/lib/bench.sh