From 8f57de52455038cb167de900e8d704eb1650bf14 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 16 Aug 2026 04:46:19 +0000 Subject: [PATCH] fix(www): run the homepage sample on /try Paste sum.echo, check it, and show captured io.print. The page names host limits and does not claim xo run or native LLVM in the browser. Bindings stay in www/public/echo-wasm/. Fixes #11 Co-authored-by: Christoffer Hallas --- crates/echo_wasm/src/lib.rs | 33 ++++++++- crates/echo_wasm/src/run.rs | 12 +++- docs/development-speed.md | 3 +- scripts/build-wasm.sh | 2 +- www/SITE.md | 19 +++--- www/package.json | 3 +- www/public/_headers | 4 ++ www/public/echo-wasm/README.md | 2 +- www/scripts/verify-try.mjs | 120 +++++++++++++++++++++++++++++++++ www/src/docs/content.ts | 4 +- www/src/lib/playground.ts | 14 ++++ www/src/try.tsx | 27 +++----- 12 files changed, 210 insertions(+), 33 deletions(-) create mode 100644 www/scripts/verify-try.mjs create mode 100644 www/src/lib/playground.ts diff --git a/crates/echo_wasm/src/lib.rs b/crates/echo_wasm/src/lib.rs index be01b4b1..0ccdbaf1 100644 --- a/crates/echo_wasm/src/lib.rs +++ b/crates/echo_wasm/src/lib.rs @@ -8,7 +8,9 @@ mod run; -pub use run::{RunResult, SAMPLE_RESULT, SAMPLE_STRUCT, SAMPLE_SUM, run_json, run_source}; +pub use run::{ + HOMEPAGE_SUM, RunResult, SAMPLE_RESULT, SAMPLE_STRUCT, SAMPLE_SUM, run_json, run_source, +}; use std::path::{Path, PathBuf}; @@ -299,6 +301,14 @@ io.print("sum={sum}") assert!(result.host_error.is_none(), "{result:?}"); } + #[test] + fn run_homepage_sum_echo_prints_sum() { + let result = run_source(HOMEPAGE_SUM); + assert!(result.ok, "{result:?}"); + assert_eq!(result.printed.as_deref(), Some("sum=6\n"), "{result:?}"); + assert!(result.host_error.is_none(), "{result:?}"); + } + #[test] fn run_result_sample_prints_ok_arm() { let result = run_source(SAMPLE_RESULT); @@ -330,7 +340,26 @@ io.print("sum={sum}") #[test] fn run_refuses_fs_as_playground_host() { - let result = run_source("/ std/fs\n/ std/io\n$ ok = fs.exists(\"x\")\n"); + assert_playground_host("/ std/fs\n/ std/io\n$ ok = fs.exists(\"x\")\n"); + } + + #[test] + fn run_refuses_net_as_playground_host() { + assert_playground_host("/ std/net/tcp\n$ c = tcp.connect(\"127.0.0.1:1\")\n"); + } + + #[test] + fn run_refuses_process_as_playground_host() { + assert_playground_host("/ std/process\n$ xs = process.args()\n"); + } + + #[test] + fn run_refuses_tasks_as_playground_host() { + assert_playground_host("+ job = {\n ^ 1\n}\n- job\n"); + } + + fn assert_playground_host(source: &str) { + let result = run_source(source); assert!(!result.ok, "{result:?}"); assert!(result.printed.is_none(), "{result:?}"); let err = result.host_error.as_deref().unwrap_or(""); diff --git a/crates/echo_wasm/src/run.rs b/crates/echo_wasm/src/run.rs index 840e547b..3fb8ae40 100644 --- a/crates/echo_wasm/src/run.rs +++ b/crates/echo_wasm/src/run.rs @@ -31,7 +31,7 @@ use crate::{CheckDiagnostic, PLAYGROUND_PATH, check_source, playground_workspace const MAX_STEPS: u32 = 2_000_000; const MAX_CALL_DEPTH: u32 = 256; -/// `/try` Sum sample (must stay in lockstep with `www/src/try.tsx`). +/// `/try` Sum sample (must stay in lockstep with `www/src/docs/site.ts` `homePage.sample`). pub const SAMPLE_SUM: &str = r#"/ std/io $ xs = [1, 2, 3] @@ -42,6 +42,16 @@ $ xs = [1, 2, 3] io.print("sum={sum}") "#; +/// Homepage `sum.echo` figure: same program as [`SAMPLE_SUM`], no trailing newline. +pub const HOMEPAGE_SUM: &str = r#"/ std/io + +$ xs = [1, 2, 3] +~ sum = 0 +* x : xs { + ~ sum = sum + x +} +io.print("sum={sum}")"#; + /// `/try` Result sample. pub const SAMPLE_RESULT: &str = r#"/ std/io / std/str diff --git a/docs/development-speed.md b/docs/development-speed.md index 794f2cd4..74ec09d9 100644 --- a/docs/development-speed.md +++ b/docs/development-speed.md @@ -175,7 +175,8 @@ just fmt-check The site playground (`/try`) runs the shared frontend (lex → parse → resolve → semantics) as `wasm32-unknown-unknown`, then a playground run executes checked MIR and captures `io.print`. It does not ship LLVM. Playground run is a host -demo; native compile and run stay on `xo`. +demo; native compile and run stay on `xo`. Filesystem, net, process, and tasks +fail with a playground-host error. Bindings stay in `www/public/echo-wasm/`. ```bash just wasm # echo_wasm + wasm-bindgen → www/public/echo-wasm/ diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh index ebb50cfd..a2ee024d 100755 --- a/scripts/build-wasm.sh +++ b/scripts/build-wasm.sh @@ -87,7 +87,7 @@ test -f "$OUT_DIR/echo_wasm_bg.wasm" # Tiny loader note used by the site when someone opens the directory. cat > "$OUT_DIR/README.md" <<'EOF' Generated by `just wasm` / `scripts/build-wasm.sh`. Do not edit. -Browser check host (frontend only). Reload `/try` after rebuilding. +Browser check + playground run host. Reload `/try` after rebuilding. EOF # Fingerprint the bindings so www can cache-bust the unhashed public URLs. diff --git a/www/SITE.md b/www/SITE.md index 782b80c5..f1176ddd 100644 --- a/www/SITE.md +++ b/www/SITE.md @@ -137,13 +137,13 @@ maturity. The logo is the only Home control. Book stays at `/book` and in the footer. -| Item | Path | Notes | -| ----------------- | ----------- | ------------------------------------------ | -| Documents | `/docs` | Language reference hub | -| Packages | `/docs/std` | Standard library | -| Echo 2026 | `/e26` | Edition + Spec TOC + suite | -| Try | `/try` | In-browser `xo check` (frontend wasm host) | -| **Install** (CTA) | `/install` | Solid button; get `xo` | +| Item | Path | Notes | +| ----------------- | ----------- | --------------------------------------------- | +| Documents | `/docs` | Language reference hub | +| Packages | `/docs/std` | Standard library | +| Echo 2026 | `/e26` | Edition + Spec TOC + suite | +| Try | `/try` | In-browser check + playground run (wasm host) | +| **Install** (CTA) | `/install` | Solid button; get `xo` | ## Docs left rail @@ -187,8 +187,9 @@ Cross-links: Reference ↔ Spec ↔ suite pages keep the triangle explicit. `/try` runs the shared compiler frontend in WebAssembly (`just wasm`). It checks source the same way `xo check` does, including bundled `std`. A -playground run then executes the checked MIR and captures `io.print`. Compile -and native run stay on `xo` (LLVM). +playground run then executes the checked MIR and captures `io.print`. +Filesystem, net, process, and tasks fail with a playground-host error. +Compile and native run stay on `xo` (LLVM). ## Out of scope (later) diff --git a/www/package.json b/www/package.json index 0aee6513..df8a39ae 100644 --- a/www/package.json +++ b/www/package.json @@ -10,10 +10,11 @@ "format": "oxfmt --check .", "lint": "oxlint . --ignore-pattern public/echo-wasm", "preview": "vite preview", - "test": "npm run test:docs && npm run test:prose && npm run test:std-ref", + "test": "npm run test:docs && npm run test:prose && npm run test:std-ref && npm run test:try", "test:docs": "node scripts/verify-docs-pages.mjs", "test:std-ref": "node scripts/verify-std-reference.mjs", "test:prose": "node scripts/verify-prose.mjs", + "test:try": "node scripts/verify-try.mjs", "sync:tree-sitter": "node scripts/sync-tree-sitter.mjs", "postinstall": "npm run sync:tree-sitter" }, diff --git a/www/public/_headers b/www/public/_headers index d71b4835..f2bb5c8f 100644 --- a/www/public/_headers +++ b/www/public/_headers @@ -1,3 +1,7 @@ # Bindings are cache-busted with ?v= from ECHO_WASM_REV; do not pin them long. /echo-wasm/* Cache-Control: public, max-age=0, must-revalidate + +/echo-wasm/*.wasm + Content-Type: application/wasm + Cache-Control: public, max-age=0, must-revalidate diff --git a/www/public/echo-wasm/README.md b/www/public/echo-wasm/README.md index b172cf91..a128108e 100644 --- a/www/public/echo-wasm/README.md +++ b/www/public/echo-wasm/README.md @@ -1,2 +1,2 @@ Generated by `just wasm` / `scripts/build-wasm.sh`. Do not edit. -Browser check host (frontend only). Reload `/try` after rebuilding. +Browser check + playground run host. Reload `/try` after rebuilding. diff --git a/www/scripts/verify-try.mjs b/www/scripts/verify-try.mjs new file mode 100644 index 00000000..dd5ecb84 --- /dev/null +++ b/www/scripts/verify-try.mjs @@ -0,0 +1,120 @@ +/** + * Verifies the /try playground contract: + * - default Sum sample is the homepage sum.echo figure + * - page copy names host limits and does not claim `xo run` or native LLVM + * - shipped www/public/echo-wasm bindings check the sample and capture io.print + * + * Loads site/playground modules through Vite SSR. Instantiates the committed + * wasm bindings in-process so Pages can ship without a Rust toolchain. + */ +import { createServer } from "vite"; +import { existsSync, readFileSync } from "node:fs"; +import { readFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const wasmDir = path.join(root, "public/echo-wasm"); +const trySourcePath = path.join(root, "src/try.tsx"); + +const server = await createServer({ + root, + logLevel: "error", + server: { middlewareMode: true }, + appType: "custom", +}); + +const failures = []; + +function fail(message) { + failures.push(message); +} + +try { + const site = await server.ssrLoadModule("/src/docs/site.ts"); + const playground = await server.ssrLoadModule("/src/lib/playground.ts"); + const tryPage = await server.ssrLoadModule("/src/try.tsx"); + + const homepage = site.homePage.sample.trim(); + const sum = playground.playgroundSumSource().trim(); + if (sum !== homepage) { + fail("playgroundSumSource must match homePage.sample"); + } + + const samples = tryPage.PLAYGROUND_SAMPLES; + const sumSample = samples?.find((sample) => sample.id === "sum"); + if (sumSample == null) { + fail("PLAYGROUND_SAMPLES must include a sum sample"); + } else if (sumSample.source.trim() !== homepage) { + fail("PLAYGROUND_SAMPLES sum source must match homePage.sample"); + } + + const limits = playground.PLAYGROUND_HOST_LIMITS; + for (const limit of ["filesystem", "net", "process", "tasks"]) { + if (!limits?.includes(limit)) { + fail(`PLAYGROUND_HOST_LIMITS missing ${limit}`); + } + } + + const trySource = readFileSync(trySourcePath, "utf8"); + if (/\bxo run\b/.test(trySource)) { + fail("/try must not describe itself as xo run"); + } + if (/native LLVM/i.test(trySource)) { + fail("/try must not claim native LLVM in the browser"); + } + for (const limit of ["filesystem", "net", "process", "tasks"]) { + if (!trySource.toLowerCase().includes(limit)) { + fail(`/try page must name host limit ${limit}`); + } + } + if (!trySource.includes("io.print")) { + fail("/try page must mention captured io.print"); + } + + const requiredWasm = ["echo_wasm.js", "echo_wasm_bg.wasm", "echo_wasm.d.ts"]; + for (const name of requiredWasm) { + if (!existsSync(path.join(wasmDir, name))) { + fail(`missing shipped binding www/public/echo-wasm/${name}`); + } + } + + if (failures.length === 0) { + const jsUrl = pathToFileURL(path.join(wasmDir, "echo_wasm.js")).href; + const wasmBytes = await readFile(path.join(wasmDir, "echo_wasm_bg.wasm")); + const wasm = await import(jsUrl); + await wasm.default({ module_or_path: wasmBytes }); + + if (typeof wasm.check !== "function" || typeof wasm.playgroundRun !== "function") { + fail("echo_wasm bindings must export check and playgroundRun"); + } else { + const checked = JSON.parse(wasm.check(site.homePage.sample)); + if (!checked.ok) { + fail(`homepage sample must check: ${JSON.stringify(checked)}`); + } + const ran = JSON.parse(wasm.playgroundRun(site.homePage.sample)); + if (!ran.ok || ran.printed !== "sum=6\n") { + fail(`homepage sample must print sum=6, got ${JSON.stringify(ran)}`); + } + } + } +} catch (error) { + fail(error instanceof Error ? error.message : String(error)); +} finally { + await server.close(); +} + +if (failures.length) { + console.error(JSON.stringify({ ok: false, failures }, null, 2)); + process.exitCode = 1; +} else { + console.log( + JSON.stringify({ + ok: true, + sample: "sum.echo", + printed: "sum=6", + wasm: "www/public/echo-wasm", + }), + ); +} diff --git a/www/src/docs/content.ts b/www/src/docs/content.ts index bb3fbd1f..3f85b838 100644 --- a/www/src/docs/content.ts +++ b/www/src/docs/content.ts @@ -463,7 +463,9 @@ io.print("sum={sum}")`, text: [ "Open ", { code: "/try" }, - " to check this program in the browser. Build ", + " to check this program and capture ", + { code: "io.print" }, + ". Build ", { code: "xo" }, " via ", { code: "/install" }, diff --git a/www/src/lib/playground.ts b/www/src/lib/playground.ts new file mode 100644 index 00000000..33dcf079 --- /dev/null +++ b/www/src/lib/playground.ts @@ -0,0 +1,14 @@ +/** + * /try contract: homepage sample, host limits, and copy that must not + * claim `xo run` or native LLVM in the browser. + */ + +import { homePage } from "../docs/site"; + +/** Sum buffer on /try. Same program as the homepage `sum.echo` figure. */ +export function playgroundSumSource(): string { + return homePage.sample.endsWith("\n") ? homePage.sample : `${homePage.sample}\n`; +} + +/** Host services the playground refuses. Keep this list on the page. */ +export const PLAYGROUND_HOST_LIMITS = ["filesystem", "net", "process", "tasks"] as const; diff --git a/www/src/try.tsx b/www/src/try.tsx index 4a39d79d..1ba39fca 100644 --- a/www/src/try.tsx +++ b/www/src/try.tsx @@ -8,6 +8,7 @@ import { type EchoCheckApi, type RunResult, } from "./lib/echo-check"; +import { playgroundSumSource } from "./lib/playground"; type Sample = { id: string; @@ -15,19 +16,11 @@ type Sample = { source: string; }; -const SAMPLES: Sample[] = [ +export const PLAYGROUND_SAMPLES: Sample[] = [ { id: "sum", label: "Sum", - source: `/ std/io - -$ xs = [1, 2, 3] -~ sum = 0 -* x : xs { - ~ sum = sum + x -} -io.print("sum={sum}") -`, + source: playgroundSumSource(), }, { id: "result", @@ -120,11 +113,12 @@ function diagnosticLabel(diag: CheckDiagnostic) { } /** - * In-browser xo check. LLVM run stays on a native xo install. + * In-browser check plus a playground run that captures io.print. + * Native compile stays on an xo install. */ export function TryPage() { - const [source, setSource] = useState(SAMPLES[0].source); - const [activeSample, setActiveSample] = useState(SAMPLES[0].id); + const [source, setSource] = useState(PLAYGROUND_SAMPLES[0].source); + const [activeSample, setActiveSample] = useState(PLAYGROUND_SAMPLES[0].id); const [api, setApi] = useState(null); const [loadError, setLoadError] = useState(null); const [result, setResult] = useState(null); @@ -237,9 +231,10 @@ export function TryPage() { Try Echo

- This page checks with the shared compiler frontend, then a playground run executes the + This page checks with the shared compiler frontend. A playground run then executes the checked program and captures{" "} - io.print. Install{" "} + io.print. Filesystem, net, + process, and tasks stay unavailable here. Install{" "} xo to compile through LLVM.

@@ -264,7 +259,7 @@ export function TryPage() {
- {SAMPLES.map((sample) => { + {PLAYGROUND_SAMPLES.map((sample) => { const selected = sample.id === activeSample; return (