Skip to content

Commit b742c4a

Browse files
rubysclaude
andcommitted
playground: self-contain + publish to Pages at /playground/
Make wasm/playground/ self-contained so it deploys as a static dir: vendor the C-ABI driver (transpile.mjs + wasi-shim.mjs) and check in the prebuilt compiler roundhouse_wasm.wasm (3.2 MB) + a seed fixture.json; playground.js now imports/fetches from ./ instead of ../browser-spike/. Add a build-site CI step that bundles _site/playground/ (copies the dir, regenerates fixture.json from the freshly-built real-blog so the editor's seed app tracks create-blog), mirroring how /blog/ is assembled. Link it from the landing page and the demo page. The compiler wasm is checked in, NOT built in CI: a runner build needs the WASI SDK + a published ruby-rbs-sys (upstream-pending as ruby/rbs#2992). Rather than block on that, the binary is committed and refreshed by hand on compiler/emit changes (see wasm/playground/README.md). Switch to a CI build + drop the binary once #2992 lands. Documented in docs/browser-demo-plan.md (new 'Publishing to Pages' section). Verified: self-contained playground and the CI-assembled bundle both pass verify-playground.mjs (Playwright); ci.yml is valid YAML. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b69da39 commit b742c4a

11 files changed

Lines changed: 263 additions & 33 deletions

File tree

.github/workflows/ci.yml

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1317,6 +1317,33 @@ jobs:
13171317
cp -R "$DEMO"/dist/. _site/blog/
13181318
echo "blog demo files:"; find _site/blog -maxdepth 2 -type f | sort
13191319
1320+
# In-browser playground demo → _site/playground/. Where the blog ships
1321+
# the *emitted app*, this ships the *compiler itself* as wasm: edit Ruby,
1322+
# transpile to any target, in the browser. The self-contained
1323+
# wasm/playground/ dir (editor + vendored driver + prebuilt
1324+
# roundhouse_wasm.wasm) copies in as-is; fixture.json — the editor's seed
1325+
# app — is regenerated from the just-built real-blog so it tracks
1326+
# create-blog. The compiler wasm is CHECKED IN, not built here: a runner
1327+
# build needs the WASI SDK + a published ruby-rbs-sys (upstream-pending as
1328+
# ruby/rbs#2992) — see wasm/playground/README.md and the "Publishing to
1329+
# Pages" section of docs/browser-demo-plan.md. Pure static files (no
1330+
# npm/vite, no COOP/COEP), so it serves straight off Pages.
1331+
- name: Bundle the in-browser playground demo (→ _site/playground)
1332+
run: |
1333+
set -euo pipefail
1334+
mkdir -p _site/playground
1335+
cp wasm/playground/index.html \
1336+
wasm/playground/editor.js \
1337+
wasm/playground/playground.js \
1338+
wasm/playground/transpile.mjs \
1339+
wasm/playground/wasi-shim.mjs \
1340+
wasm/playground/roundhouse_wasm.wasm \
1341+
_site/playground/
1342+
# Fresh seed app from the just-generated real-blog fixture.
1343+
(cd wasm/browser-spike && node gen-fixture.mjs)
1344+
cp wasm/browser-spike/fixture.json _site/playground/fixture.json
1345+
echo "playground files:"; find _site/playground -type f | sort
1346+
13201347
# Bench results page — fetched from the Hetzner publishing endpoint
13211348
# (cron-driven, populates bench/results/latest on each run). The
13221349
# render step degrades gracefully: if Hetzner is unreachable, the

docs/browser-demo-plan.md

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,50 @@ verifiable development cycle."**
115115
Total: rung A alone **~2–3 days**; full arc through D.2 **~9–14 days**.
116116
A is independently shippable and de-risks everything after it.
117117

118+
## Publishing to Pages
119+
120+
The demos publish to `rubys.github.io/roundhouse/` through the same CI
121+
`build-site` job that ships `/blog/`, `/browse/`, and `/bench/`. The job
122+
assembles `_site/` (via `roundhouse::project::build_site`, which copies `site/`
123+
wholesale + writes the `browse/` archives) and then layers on the dynamic
124+
demos as extra steps before `upload-pages-artifact`. Two kinds of content:
125+
126+
- **Static** → anything under `site/` is copied as-is (this is how `/demo/`
127+
and the landing page ship). Links to the playground live in
128+
`site/index.html` and `site/demo/index.html`.
129+
- **Generated** → a CI step writes into `_site/<dir>/`. `/blog/` emits the
130+
worker-profile app and vite-builds it; **`/playground/`** copies the
131+
self-contained `wasm/playground/` dir and regenerates `fixture.json` from the
132+
just-built real-blog fixture (CI step "Bundle the in-browser playground
133+
demo").
134+
135+
**The playground is published with a checked-in compiler wasm — deliberately,
136+
not blocked on #2992.** Building `roundhouse_wasm.wasm` on a runner needs the
137+
WASI SDK *and* a `ruby-rbs-sys` reachable from CI; today that's a local-path
138+
`[patch.crates-io]` (the wasm32 build support is upstream-pending as
139+
ruby/rbs#2992). Rather than wait — it may be days, weeks, or never — the
140+
~3.2 MB `wasm/playground/roundhouse_wasm.wasm` is committed and the CI step
141+
just copies it. The cost is a manual refresh: after compiler/emit changes,
142+
rebuild and recommit the binary (`WASI_SDK_PATH=/opt/wasi-sdk cargo build
143+
--release --target wasm32-wasip1` in `wasm/`, copy it in, re-run
144+
`verify-playground.mjs`). See `wasm/playground/README.md` § Maintenance.
145+
146+
**Switch-to-CI-build trigger:** when #2992 merges and `ruby-rbs-sys` publishes
147+
(or the patched crate is pushed to a CI-reachable git ref), replace the copy
148+
with a build step (install WASI SDK → `cargo build --target wasm32-wasip1`
149+
regenerate fixture) and delete the checked-in binary. That closes honest-gap
150+
#1's second half. Until then, the playground is the only published surface
151+
carrying a binary artifact in git.
152+
153+
The playground is pure static files (no npm/vite at serve time, no COOP/COEP),
154+
so — like `/blog/` — it serves straight off plain GitHub Pages. One thing to
155+
eyeball on the **first** deploy: that Pages serves the `.mjs` modules with a
156+
JS MIME (ES-module imports are MIME-strict) and the `./roundhouse_wasm.wasm`
157+
fetch succeeds. `transpile.mjs` uses `WebAssembly.instantiate(arrayBuffer)`
158+
(not `instantiateStreaming`), so the wasm's own MIME is irrelevant — only that
159+
the bytes load. If a `.mjs` MIME ever bites, rename the two driver modules to
160+
`.js`.
161+
118162
## The honest last-mile gaps (read before estimating)
119163

120164
1. **WASI-in-browser (rung A, Phase 0). — RESOLVED.** The hand-rolled

site/demo/index.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,8 @@ <h2>How it's tested</h2>
176176
<h2>See it</h2>
177177
<p>
178178
<a href="../blog/">Run the blog live</a> (TypeScript worker target) ·
179+
<a href="../playground/">Transpile it yourself</a> in the browser — edit
180+
the Ruby, watch any target's output update live ·
179181
<a href="../browse/">Browse the generated output</a> for every target ·
180182
<a href="../bench/">benchmark results</a> across the live targets ·
181183
<a href="../create-blog">download the generator script</a> ·

site/index.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ <h1>Roundhouse</h1>
2121

2222
<nav class="landing-nav">
2323
<a href="demo/">The demo blog</a>
24+
<a href="playground/">Playground</a>
2425
<a href="browse/">Browse generated output</a>
2526
<a href="bench/">Benchmark results</a>
2627
<a href="https://github.com/rubys/roundhouse">GitHub</a>

wasm/playground/README.md

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,9 @@ The multi-target playground from `docs/browser-demo-plan.md` Phase 1: an
44
editable Monaco source tree → in-browser `transpile` (wasm) → emitted output,
55
with a target dropdown. No WebContainer, no npm, no bundler.
66

7-
Builds directly on the Phase 0 spike (`../browser-spike/`): it reuses that
8-
dir's `transpile.mjs` driver and its already-generated `roundhouse_wasm.wasm`
9-
+ `fixture.json`. What's net-new over the spike is the **editable** loop —
10-
source tree, editor, debounced edit → re-transpile.
7+
**Self-contained**: every asset it needs sits in this directory, so the whole
8+
dir copies straight to GitHub Pages at `/playground/` with no rewrite. This is
9+
what gets published (see "Publishing" below).
1110

1211
## Files
1312

@@ -16,22 +15,22 @@ source tree, editor, debounced edit → re-transpile.
1615
| `index.html` | three-pane layout (sources / editor / output) | yes |
1716
| `editor.js` | editor abstraction: Monaco via CDN, `<textarea>` fallback | yes |
1817
| `playground.js` | app: tree, debounced transpile loop, output, test hooks | yes |
18+
| `transpile.mjs` / `wasi-shim.mjs` | C-ABI driver (vendored from `../browser-spike/`) | yes |
19+
| `roundhouse_wasm.wasm` | **prebuilt** compiler (~3.2 MB) — see Maintenance | yes |
20+
| `fixture.json` | seed app (real-blog), generated by `../browser-spike/gen-fixture.mjs` | yes |
1921
| `verify-playground.mjs` | Playwright: drive edit→transpile→render in chromium | yes |
2022
| `playground.png` | screenshot from the verifier | no (gitignored) |
2123

2224
## Run
2325

24-
Serve the **`wasm/` directory** (one level up — so `../browser-spike/`
25-
imports + fetches resolve), then open `/playground/`:
26+
It's self-contained — serve **this** directory as the web root:
2627

2728
```sh
28-
# from wasm/ (after the spike's artifacts exist — see ../browser-spike/README.md):
29-
# ../browser-spike/roundhouse_wasm.wasm and ../browser-spike/fixture.json
30-
python3 -m http.server 8099 # run from wasm/
31-
open http://localhost:8099/playground/
29+
python3 -m http.server 8099 # run from wasm/playground/
30+
open http://localhost:8099/
3231

3332
# automated smoke check (chromium via tests/browser_smoke/node_modules):
34-
node playground/verify-playground.mjs
33+
node verify-playground.mjs
3534
```
3635

3736
## What works
@@ -43,12 +42,31 @@ node playground/verify-playground.mjs
4342
- Monaco loads from a CDN; offline / headless / strict-CSP falls back to a
4443
`<textarea>` (the loop is identical through both — see `editor.js`).
4544

45+
## Publishing
46+
47+
The CI `build-site` job copies this dir into `_site/playground/` and
48+
regenerates `fixture.json` from the freshly-built real-blog fixture, so the
49+
published demo lands at `rubys.github.io/roundhouse/playground/`. See the
50+
"Publishing to Pages" section of `docs/browser-demo-plan.md`.
51+
52+
## Maintenance (the prebuilt wasm)
53+
54+
`roundhouse_wasm.wasm` is **checked in**, not built in CI — building it on a
55+
runner needs the WASI SDK plus a published `ruby-rbs-sys` (the wasm32 build
56+
support is upstream-pending as ruby/rbs#2992). Until that lands, refresh the
57+
compiler by hand after compiler/emit changes:
58+
59+
```sh
60+
WASI_SDK_PATH=/opt/wasi-sdk cargo build --release --target wasm32-wasip1 # in wasm/
61+
cp ../target/wasm32-wasip1/release/roundhouse_wasm.wasm .
62+
node verify-playground.mjs # confirm still green, then commit the new wasm
63+
```
64+
65+
When #2992 (or a published patched `ruby-rbs-sys`) lands, switch the CI step to
66+
build the wasm and drop the checked-in binary.
67+
4668
## Known gaps / follow-ons
4769

48-
- **Not yet packaged as a self-contained deploy dir.** It currently imports +
49-
fetches from `../browser-spike/`, so it must be served from `wasm/` root.
50-
Promoting the shared driver + artifacts into a deploy bundle (for
51-
`rubys.github.io/roundhouse/playground/`) is follow-on packaging.
5270
- **ruby / spinel / kotlin / swift** are not in the target list — they aren't
5371
wired into `wasm/src/lib.rs`'s `match` yet (a one-line addition each).
5472
- **Output is read-only `<pre>`** (no syntax highlight); Monaco-for-output is

wasm/playground/fixture.json

Lines changed: 1 addition & 0 deletions
Large diffs are not rendered by default.

wasm/playground/playground.js

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,19 @@
11
// Phase 1 — multi-target playground (rung A of docs/browser-demo-plan.md).
22
//
3-
// Builds directly on the Phase 0 spike: reuses ../browser-spike/transpile.mjs
4-
// (the C-ABI driver) and the spike's already-generated artifacts
5-
// (roundhouse_wasm.wasm + fixture.json). What's net-new here over the spike:
6-
// an EDITABLE multi-file source tree, an editor (Monaco w/ textarea fallback),
7-
// and a debounced edit -> transpile -> render loop.
3+
// Self-contained: every asset it needs (the C-ABI driver transpile.mjs +
4+
// wasi-shim.mjs, the compiler roundhouse_wasm.wasm, and the seed app
5+
// fixture.json) sits in THIS directory, so the whole dir copies straight to
6+
// GitHub Pages at /playground/ with no rewrite. transpile.mjs + wasi-shim.mjs
7+
// are vendored copies of the Phase 0 spike's driver (kept in sync by hand —
8+
// they're small and stable). What's net-new over the spike: an EDITABLE
9+
// source tree, an editor (Monaco w/ textarea fallback), and a debounced
10+
// edit -> transpile -> render loop.
811
//
9-
// Serve from the wasm/ directory root and visit /playground/ so the
10-
// ../browser-spike/ imports + fetches resolve. (Self-contained packaging into
11-
// its own deploy dir is a follow-on — see README.)
12+
// Serve THIS directory as the web root (e.g. `python3 -m http.server` here).
1213

13-
import { loadCompiler } from "../browser-spike/transpile.mjs";
14+
import { loadCompiler } from "./transpile.mjs";
1415
import { createEditor } from "./editor.js";
1516

16-
// Shared driver + artifacts live in the spike dir, one level up from here.
17-
const SPIKE = "../browser-spike";
18-
1917
// The six targets the wasm entry point routes to today. (ruby/spinel/kotlin/
2018
// swift are not yet wired into wasm/src/lib.rs — a one-line match extension.)
2119
const TARGETS = ["typescript", "go", "rust", "python", "elixir", "crystal"];
@@ -144,8 +142,8 @@ async function boot() {
144142

145143
setStatus("loading wasm + fixture…");
146144
const [wasmBytes, fixture] = await Promise.all([
147-
fetch(`${SPIKE}/roundhouse_wasm.wasm`).then((r) => r.arrayBuffer()),
148-
fetch(`${SPIKE}/fixture.json`).then((r) => r.json()),
145+
fetch("./roundhouse_wasm.wasm").then((r) => r.arrayBuffer()),
146+
fetch("./fixture.json").then((r) => r.json()),
149147
]);
150148
srcMap = fixture;
151149
compiler = await loadCompiler(wasmBytes, {
3.23 MB
Binary file not shown.

wasm/playground/transpile.mjs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Shared compiler driver: wraps the roundhouse-wasm C-ABI
2+
// (rh_alloc / transpile / rh_dealloc, packed u64 return) in a clean
3+
// transpile(language, srcMap) -> { files | error } interface. Uses only
4+
// WebAssembly + TextEncoder/Decoder, so the same module drives both the
5+
// Node validator and the browser page.
6+
7+
import { makeWasi } from "./wasi-shim.mjs";
8+
9+
// wasmBytes: ArrayBuffer | Uint8Array of roundhouse_wasm.wasm.
10+
// Returns { transpile(language, srcMap) } where srcMap is { path: content }.
11+
export async function loadCompiler(wasmBytes, wasiOpts = {}) {
12+
const memoryRef = { value: null };
13+
const wasi = makeWasi(memoryRef, wasiOpts);
14+
const { instance } = await WebAssembly.instantiate(wasmBytes, {
15+
wasi_snapshot_preview1: wasi,
16+
});
17+
memoryRef.value = instance.exports.memory;
18+
if (instance.exports._initialize) instance.exports._initialize();
19+
20+
const { rh_alloc, rh_dealloc, transpile, memory } = instance.exports;
21+
const encoder = new TextEncoder();
22+
const decoder = new TextDecoder();
23+
24+
return {
25+
transpile(language, srcMap) {
26+
const input = JSON.stringify({ language, src: srcMap });
27+
const inputBytes = encoder.encode(input);
28+
29+
const inputPtr = rh_alloc(inputBytes.length);
30+
new Uint8Array(memory.buffer, inputPtr, inputBytes.length).set(inputBytes);
31+
32+
const packed = transpile(inputPtr, inputBytes.length);
33+
const outPtr = Number(packed & 0xffffffffn);
34+
const outLen = Number(packed >> 32n);
35+
36+
const outBytes = new Uint8Array(memory.buffer, outPtr, outLen).slice();
37+
const output = decoder.decode(outBytes);
38+
39+
rh_dealloc(inputPtr, inputBytes.length);
40+
rh_dealloc(outPtr, outLen);
41+
42+
return JSON.parse(output);
43+
},
44+
};
45+
}

wasm/playground/verify-playground.mjs

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,15 @@
1414
// the transpiler reflects recognized Rails DSL like `validates`, not arbitrary
1515
// methods — so the edit assertion uses a validation, which IS reflected.)
1616
//
17-
// Serve the wasm/ directory (NOT this dir) so ../browser-spike/ resolves:
18-
// python3 -m http.server 8099 # run from wasm/
19-
// node playground/verify-playground.mjs
17+
// Serve THIS directory as the web root (it's self-contained):
18+
// python3 -m http.server 8099 # run from wasm/playground/
19+
// node verify-playground.mjs
2020

2121
import { createRequire } from "node:module";
2222
const require = createRequire("/Users/rubys/git/roundhouse/tests/browser_smoke/");
2323
const { chromium } = require("playwright");
2424

25-
const URL = "http://localhost:8099/playground/index.html";
25+
const URL = "http://localhost:8099/index.html";
2626
const MODEL = "app/models/article.rb";
2727

2828
const browser = await chromium.launch();

0 commit comments

Comments
 (0)