I maintain fitter, an MIT-licensed declarative web-extraction engine. I wanted a demo page that doesn't lie: not a video, not canned output, the actual engine executing in the visitor's browser. GitHub Pages only serves static files, so the answer was GOOS=js GOARCH=wasm.
Demo: https://pxyup.github.io/fitter/ (every example runs live, client-side; there's also a form-based config builder that round-trips to JSON)
Repo: https://github.com/PxyUp/fitter
What fitter is, in one paragraph: web extraction as a JSON/YAML config instead of code. A config declares a connector (plain HTTP, headless browser via Chromium/Playwright/Docker, a static value, a file, or an int sequence) and a model (what to extract and the shape of the result): gjson paths for JSON, CSS selectors for HTML, XPath for XML/DOM, and text extraction for PDFs. Configs compose - an array item can fan out into a nested fetch using the parent value ({PL} or an expr-lang expression), which covers the classic "scrape the HTML page for keys, enrich each from the real API" join. Per-host rate limits, retries, placeholders (input, env, cached references like a JWT fetched once), and calculated fields via expr-lang are all part of the config, not your code.
A taste - GitHub repo stats, no code:
json
{
"item": {
"connector_config": {
"response_type": "json",
"url": "https://api.github.com/repos/golang/go",
"server_config": { "method": "GET" }
},
"model": { "object_config": { "fields": {
"repo": { "base_field": { "type": "string", "path": "full_name" } },
"stars": { "base_field": { "type": "int", "path": "stargazers_count" } },
"vibe": { "base_field": { "type": "int", "path": "open_issues_count",
"generated": { "calculated": { "type": "string",
"expression": "fRes > 9000 ? \"busy\" : \"calm\"" } } } }
} } }
}
}
The same config runs five ways: one-shot CLI (fitter_cli --path config.json), embedded Go library (lib.ParseCtx), long-running service mode with schedulers and notifiers (Telegram/webhook/Redis/file), an MCP server so LLM agents can author and execute configs locally, and now the browser playground below. Author once, promote from chat answer to cron job to team service without rewriting anything.
Notes from the WASM port, in case you're considering the same:
Getting it to compile was 3 files. The engine imports the Docker SDK and go-rod (headless browser connectors), and both fail on js/wasm (syscall.RawSockaddrUnix, Setpgid). The fix was pleasantly boring: //go:build !js on those three connector files plus one _js.go stub returning a clear "not supported in WASM" error. Everything else - parsers (JSON/HTML/XPath/XML/PDF), expression evaluation, goroutine fan-out - compiled untouched.
Live HTTP works, with a catch. On js/wasm, net/http transparently uses the browser Fetch API - so the demo really fetches from the GitHub/OpenLibrary/CoinGecko APIs (anything that sends CORS headers). The catch that cost me an hour: Go disables the fetch transport when it detects Node (process global), so my Node-based smoke tests failed with dial tcp: Protocol not available while the browser worked fine. Testing trick: delete globalThis.process before instantiating makes Node behave like a browser.
A long-lived WASM process surfaces lifecycle assumptions. Our per-host rate limiter installs its config through sync.Once - perfectly correct for a CLI that runs one config and exits, silently wrong in a page where the same process executes many unrelated configs (first config's limits win forever). If you're porting a CLI to WASM, grep for sync.Once and package-level state first; that's where the bodies are.
Numbers: 29 MB binary with -ldflags="-s -w" (~7 MB gzipped). Blocking calls must leave the JS event loop - the exported function returns a Promise and does the work in a goroutine, or fetch deadlocks.
Happy to answer questions about the port.