| name | machin-web |
| description | Build web apps in machin (MFL) — a native HTTP server, a JSON API, server-side rendering, and a reactive WebAssembly UI, all in one language with no Node/bundler. Use when writing or debugging a machin web app: a backend service, an SSR page, a wasm SPA, an isomorphic full-stack app, or a CRUD back-office. Covers the machweb/reactive/router/flags frameworks, cookies + signed sessions, OAuth2/OIDC SSO, the wasm bridge + host↔wasm marshaling, the generic JS host, the pure-MFL Postgres/MySQL/Mongo/Redis drivers, build-and-verify, and the hard-won gotchas. Distilled from the web + backend dogfood (machin v0.50–v0.73). |
Building web apps in machin
machin compiles MFL to a native binary (the server) and to WebAssembly
(the browser client). One language does both ends of the wire — server, API, SSR
HTML, and a fine-grained reactive UI — with no Node, no bundler, no
node_modules. The JS host is a fixed ~30 lines; everything else is MFL.
Run machin guide first — it's the version-exact language catalog (builtins,
idioms, gotchas), including a reactive-runtime note. This skill is the web how-to.
The fastest path: start from the boilerplate
boilerplate-cli-ui-machin-isomorphic
is a working single binary that is a CLI + HTTP server + JSON API + reactive wasm
UI (SSR + hydration, shared model). Clone it and edit — it already wires up
everything below. To build something new, copy its structure:
src/machweb.src src/flags.src src/reactive.src # vendored frameworks (from machin/framework)
src/models.src # shared schema/render, compiled into BOTH server and client
src/client.src # the wasm UI (reactive)
src/server.src # machweb routes + CLI main; serves its own wasm + the API
web/host.js # the generic JS host, embedded into the binary at build time
build.sh
build.sh does two compiles: the client to wasm, then the server (which embeds
web/host.js and serves app.wasm):
machin encode src/reactive.src src/models.src src/client.src > client.mfl
machin build client.mfl --target wasm -o app.wasm
python3 -c "import json;print('func host_js() (s) { s = '+json.dumps(open('web/host.js').read())+' }')" > src/host_gen.src
machin encode src/machweb.src src/flags.src src/models.src src/server.src src/host_gen.src > server.mfl
machin build server.mfl -o app
Needs machin (v0.57.0+ for forms) and zig (the C→wasm
compiler — a single binary, no emscripten). The frameworks live in
machin/framework; vendor copies into your repo.
The server — framework/machweb.src
A handler is func(Request) Response. serve(port, handler) runs it (one
goroutine per connection). req.method / req.path (path carries the query
string) / req.body / header(req, name) / cookie(req, name).
Response builders: ok_text · ok_html · ok_json · ok_bytes(ctype, b) ·
ok_wasm(b) · created · bad_request · not_found. Add any header with
with_header(res, name, value) (e.g. Content-Disposition for a download filename).
func handle(req) (res) {
if req.path == "/app.wasm" { return ok_wasm(read_file_bytes("app.wasm")) }
if has_prefix(req.path, "/api/users") { return ok_json(users_json()) }
return ok_html(page())
}
func main() { serve(48080, func(req) { return handle(req) }) }
- Serve your own wasm:
ok_wasm(read_file_bytes("app.wasm")) — read_file_bytes
is NUL-safe (a .wasm has NUL bytes a string body would truncate).
- File uploads (
multipart/form-data): requests are read binary-safe, so a browser
file upload survives intact. multipart_file(req, "file") → (part, ok) with
part.filename / part.ctype / part.data (raw bytes — store with
write_file_bytes); multipart_field(req, "name") reads a text field of the same form.
parse_multipart(req) returns every part. The raw binary body is req.body_bytes. See
machin-share.
- Streaming / Server-Sent Events: return
sse(func(conn) { ... }) instead of a whole
Response and write events over the open socket with sse_data(conn, msg) /
sse_event(conn, name, msg) / sse_comment(conn, ping) — for live logs, progress, or
LLM tokens. A write returns < 0 once the client disconnects (break the loop). SIGPIPE
is ignored so a vanished client can't kill the server. For cross-goroutine fan-out
(one producer → many live subscribers) use a single hub goroutine that owns the
subscriber set (a chan field inside a struct, a []Sub registry) — see
machin-live.
- WebSockets (
framework/ws.src): compose ws.src after machweb.src, then a handler
returns ws(req, func(c) { ... }). machweb does the upgrade (via the generic
hijack(fn) response) and c is a WSConn: ws_next_text(c) → (msg, ok) reads the
next message (answering pings, stopping on close); ws_send_text(c.fd, s) /
ws_send_bytes(c.fd, b) send. A connection that also receives broadcasts wants two
goroutines — the handler loop reads, a spawned go writer(c.fd, ch) drains an outbound
channel (read + write are independent on the fd). Fan-out is the same hub-goroutine
pattern as SSE. See machin-rooms and
(a 71 KB metrics
dashboard: HTTP + WS + hub broadcast + a for REST endpoints
that need the current state synchronously without waiting for the next broadcast).
The client — framework/reactive.src (signals + a patch list)
The Solid/Leptos model in MFL: state in signals, fine-grained updates. Only the
reactions that read a changed signal recompute, and only changed text/keys touch
the DOM (no innerHTML churn, no vdom diff).
| call | what it does |
|---|
signal(v) -> id | a state cell; get(id) reads (auto-tracks a dependency), set(id, v) writes (notifies dependents if changed) |
computed(func(){ return … }) -> id | a memoized derived signal; read with get like any signal |
slot(name, compute) -> markup | markup for a reactive text node (<span data-s=name>) + queues its binding |
list(name, keys, item) -> markup | markup for a keyed list + queues its reconciler. keys() returns the ordered keys as a CSV string; item(key) returns one item's HTML |
mount(root, html) | set the root's innerHTML once, then activate the queued slots/lists (client-side render) |
hydrate(html) | activate them against an already-SSR'd DOM (no re-render) — for isomorphic pages |
Multi-page (framework/router.src). For an admin app with several pages, compose
the router too. The active route is a signal (an int index): router_init(0),
route(path) registers pages in order, link(path, label) renders a nav anchor,
current_route() reads the active index, and outlet(id, render) re-renders the
active page (a reaction over a dom_html host import) and syncs the address bar.
page() switches on current_route() and must be a pure render (read signals,
never set — that loops). path_index(path) returns -1 for an unregistered
path, so a page() can render a real 404 instead of silently showing route 0;
navigate ignores an out-of-range index (either end), so an unknown link is inert
rather than a crash. router_init also clears the route table, so calling it
again gives a clean slate. The host adds dom_html/nav_url, forwards [data-nav]
clicks to nav(path) (path in via ptr_str) + popstate, and a catch-all server
serves the shell for any path (deep-links). See machin-web-demo-router.
A whole component is one expression:
export func start() {
n = signal(0)
total = computed(func() { get(ver) return sum_of(items) })
mount("app",
"<h1>Items</h1>" +
slot("total", func() { return str(get(total)) }) +
list("items", func() { get(ver) return csv(ids) }, func(id) { return row(id) }))
}
The host supplies five DOM ops as imports: dom_mount · dom_patch ·
list_insert · list_remove · list_order (see the host below).
Keyed lists only re-render an item on INSERT, not when a kept item's content
changes. To make a row update when its data changes, encode the mutable state in
the key (key = id*100 + done); a change makes a new key → one remove+insert.
The wasm bridge & marshaling
The generic JS host (reusable as-is)
let mem; const dec = new TextDecoder(), enc = new TextEncoder();
const cstr = p => { const b = new Uint8Array(mem.buffer); let e=p; while(b[e])e++; return dec.decode(b.subarray(p,e)); };
const env = {
dom_mount: (r,h) => { document.getElementById(cstr(r)).innerHTML = cstr(h); },
dom_patch: (s,v) => { const el = document.querySelector('[data-s="'+cstr(s)+'"]'); if (el) el.textContent = cstr(v); },
list_insert:(c,k,h)=> { const li=document.createElement('li'); li.dataset.k=cstr(k); li.innerHTML=cstr(h); document.getElementById((c)).(li); },
: { el=.(+(c)++(k)+); (el) el.(); },
: { ct=.((c)); ( k (csv).().()) { el=ct.(+k+); (el) ct.(el); } },
};
wasi = { :, :, :, : };
{ instance } = .((), { env, : wasi });
mem = instance..; instance..?.();
instance..();
Build & verify (this environment)
zig is on PATH (/snap/bin/zig); machin build --target wasm uses it.
- Serve over http (
python3 -m http.server) — wasm won't load from file://.
- Screenshot to verify rendering:
google-chrome --headless=new --screenshot=/tmp/x.png --window-size=W,H http://localhost:PORT/ then read the PNG. To exercise interaction, inject a small autopilot <script> (set an input's value + click) since there's no click injector.
- Verify reactivity headlessly in node: instantiate
app.wasm with stub imports that record dom_patch/list_* calls, drive the exports, and assert the patch list is minimal.
Gotchas (hard-won)
- No-op WASI stub: the reactive runtime's indirect closure calls keep wasi-libc's
float-
snprintf path, so the wasm imports wasi_snapshot_preview1.{fd_write,fd_seek,fd_close,fd_fdstat_get}. They're never called — provide the ~4 no-op stubs above.
- A function named like a builtin is a COMPILE ERROR (
flush/keys/contains/len/str/…). Rename it. (An extern may shadow — that's for FFI.)
- Lambdas have no named returns:
func() (s) { s = x } doesn't parse — use func() { return x }.
- Function scope, not block scope: a variable used as two types across
if branches conflicts; give each branch its own name.
- Package globals (
var x = 0 at top level) hold a component's state across export calls (persist in the wasm instance); = assigns the global, := shadows with a local.
- Closures over a loop variable see its final value (captured by reference) — build per-item closures via a helper that takes the index as a parameter (fresh per call), not inside the loop.
- Two
extern "env" blocks compose fine (the runtime's DOM ops + your app's effect imports).
- Reactive signals are INT-only —
reactive.src's sval is []int{}, so signal("") fails to typecheck. Hold non-int state in globals; bump an int version signal (set(ver, get(ver)+1)) to trigger an outlet re-render.
- A NAMED function can't be passed as a value —
route(r, "/", myHandler) → "undefined variable". Wrap it in a closure: route(r, "/", func(req){ return myHandler(req) }) (the router examples all pass inline closures for this reason).
- Slice literals must be
[]string{...}, not [a,b] — bare brackets parse as an index expression. Bound params to sqlite_exec/sqlite_query are []string{name, str(id)}, never [name, str(id)].
Returning effect imports (HTTP from the wasm client)
Existing demos never let the WASM client initiate a server call — JS caught the
click, did the fetch, and fed the result back into MFL via a load() export. Returning
extern "env" imports invert that: MFL says data := http_get(url) and uses the
result inline (a synchronous XHR blocks under wasm). Two things must line up:
extern "env" {
fn http_get(string) string
fn http_post(string, string) string
}
export func alloc_export(n) (p) { p = alloc(n) }
func fetch_page(slug) {
page_str = http_get("/api/page/" + slug)
set(ver, get(ver) + 1)
}
http_get: (urlPtr) => {
const b = enc.encode(cstr(urlPtr));
const xhr = new XMLHttpRequest(); xhr.open('GET', cstr(urlPtr), false); xhr.send();
const r = enc.encode(xhr.responseText + '\0');
const p = Number(instance.exports.alloc_export(BigInt(r.length)));
new Uint8Array(mem.buffer).set(r, p); return p;
},
This is the enabler for a wasm SPA that talks to its own server API — and the moment
enough endpoints are hand-bridged, the boilerplate motivates a machin gen-client
(typed RPC, the next north-star gap).
Recipe: a CRUD back-office (e.g. manage a users DB)
- Schema (
models.src, shared): a user_row(id, name, email) -> html used by
both SSR and the client list item.
- Server (
server.src): sqlite_open("users.db"), create the table; routes —
GET / SSR-renders the user list (matching data-s names so the client
hydrates), GET /app.wasm serves the client, GET /api/users returns rows as
JSON (ok_json(json(parse(sqlite_query(...), []User{}))) or just the raw query
string), POST /api/users inserts (parse the body), DELETE/POST /api/users/del
removes. Use parameterized sqlite_exec(db, sql, params) — never string-concat SQL.
- The POST body shape depends on the sender. A
fetch with a JSON body →
parse(req.body, User{}) or json_get. A browser <form> (non-JS fallback, or
Content-Type: application/x-www-form-urlencoded) → the body is name=Ada&email=a%40b;
decode each field with the url_decode builtin (don't hand-roll it — a function
named url_decode is a compile error, it shadows the builtin). A tiny helper:
func form_field(body, key) (v) {
for _, kv := range split(body, "&") {
eq := index(kv, "=")
if eq > 0 && substr(kv, 0, eq) == key { v = url_decode(substr(kv, eq+1, len(kv))) }
}
}
- Client (
client.src): signals hold the rows; each renders the table
(re-key on any mutable field); a form (<input> + ptr_str) adds a user and
POSTs; row buttons toggle/delete and call the API. A shows the count.
The state lives in machin; the server is the source of truth (SQLite); the client is
reactive over the API. One binary, one language.
Worked implementation: machin-web-demo-users — the exact app above, end to end. (For the isomorphic shape instead, see the boilerplate.)
Pointers