| 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.
- A database: machin has SQLite builtins —
sqlite_open(path) / sqlite_exec(db, sql) / sqlite_exec(db, sql, []string params) (?-bind, injection-safe) /
sqlite_query(db, sql[, params]) -> string (a JSON-array-of-rows string) /
sqlite_close(db). Perfect for a users table behind a JSON API. Decode rows with
parse, not json_get:
type User struct { id int name string email string }
rows := sqlite_query(db, "SELECT id, name, email FROM users ORDER BY id")
users := parse(rows, []User{})
A slice witness ([]User{}) is the row-iteration idiom. Reach for json_get(rows, "[0].name") only to pull ONE field — and note it returns the raw JSON token, so a
string comes back quoted ("Ada"); strip the quotes (substr(s, 1, len(s)-1)) or
just use parse. Always parameterize (?, []string{...}); never concat user input.
- A networked database (shared across instances): machin has pure-MFL drivers for
PostgreSQL, MySQL/MariaDB, Redis, and MongoDB (all connection-pooled,
no cgo) — each returns rows as JSON the same way, so
parse(rows, []T{}) decodes them.
See the postgres-client / mysql-client / mongo-client / redis-client /
machweb-sessions / sso-oauth idioms in machin guide, and
docs/NORTH-STAR-BACKEND.md for the full backend
story (sessions, SSO, pooling, and the worked MachNotes / machin-cms apps).
- A CLI: compose
framework/flags.src — new_flags / flag_int / flag_str /
flag_bool / parse_flags(fs, args()) / flag_int_val / flag_on(fs,"help")
(auto --help).
- Cookies & login sessions:
cookie(req, name) reads a request cookie;
set_cookie(res, name, value) / clear_cookie(res, name) return a Response with
a Set-Cookie (safe defaults: Path=/; HttpOnly; SameSite=Lax). For login, use a
signed session the client can't forge: set_session(res, secret, "sid", userID)
stores userID + an HMAC tag, and get_session(req, secret, "sid") -> (value, ok)
returns ok == 1 only if it verifies. Response is a value, so chain the helper:
func login(req) (res) {
res = set_session(ok_html(dashboard()), secret, "sid", "user:42")
}
func page(req) (res) {
uid, ok := get_session(req, secret, "sid")
if ok == 0 { return set_cookie(not_found(), "x", "") }
res = ok_html(home(uid))
}
Keep secret server-side (e.g. env("SESSION_SECRET")). The value is signed, not
encrypted — store an id/handle, not secrets.
- SSO — "log in with Google/Microsoft/…" (
framework/sso.src, OAuth2 + OIDC). Fill
an OAuthProvider{auth_url, token_url, userinfo_url, client_id, client_secret, redirect_uri, scope}, then two routes: GET /login → sso_begin(p, secret) (302 to
the provider + a signed oauth_state cookie for CSRF); GET /callback →
profile, ok := sso_complete(p, secret, req) (verifies state, exchanges the code,
fetches the userinfo JSON). On ok read json_get(profile, ".sub")/".email" and
set_session(...) your own login cookie, then redirect("/"). Identity comes from the
provider's userinfo endpoint (so no JWT/RSA needed). Also uses redirect(url) and
query(req, name) (now in machweb). See examples/the SSO test for the full flow.
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). 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(cstr(c)).appendChild(li); },
list_remove:(c,k) => { const el=document.querySelector('#'+cstr(c)+' > [data-k="'+cstr(k)+'"]'); if (el) el.remove(); },
list_order: (c,csv)=> { const ct=document.getElementById(cstr(c)); for (const k of cstr(csv).split(',').filter(Boolean)) { const el=ct.querySelector('[data-k="'+k+'"]'); if (el) ct.appendChild(el); } },
};
const wasi = { fd_write:()=>0, fd_seek:()=>0, fd_close:()=>0, fd_fdstat_get:()=>0 };
const { instance } = await WebAssembly.instantiateStreaming(fetch('/app.wasm'), { env, wasi_snapshot_preview1: wasi });
mem = instance.exports.memory; instance.exports._initialize?.();
instance.exports.start();
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)].
sqlite_query returns a JSON ARRAY of rows — {"user":[{...}]} not {"user":{...}}. And json_get's path language does NOT compose key[idx].field (key → array index → field) — it returns err. For API responses use typed parse(body, Struct{}) with a witness that mirrors the shape, e.g. type Me struct { user []User } → parse(me, Me{}).
- Returning values from
extern "env" imports WORK — fn http_get(string) string compiles and runs (first exercised by machin-wiki); the JS host returns a pointer into wasm linear memory. BUT the alloc builtin is NOT exported, so the host can't write the return string without a buffer-allocator export. See Returning effect imports below.
- SPA catch-all server routing —
serve_router does exact METHOD PATH match and 404s client-side routes (/new, /page/slug). Use serve(port, handler) with a catch-all instead: if has_prefix(path,"/api/"){return dispatch(r,req)}; if path=="/app.wasm"{return wasm}; else return shell(req) — so the wasm router hydrates the deep link.
- A trailing-slash API prefix (
route(r,"/api/page/",…)) won't exact-match /api/page/home. Handle the prefix path in the catch-all (if has_prefix(path,"/api/page/"){return api_page(req)}) before handing the rest to dispatch.
- Deep-link testing under headless Chrome —
--dump-dom/--screenshot return BEFORE the initial-navigation setTimeout fires; pass --virtual-time-budget=2000 so the SPA's first navigate runs first, or you'll falsely see the home view.
- Host→wasm callbacks already work — JS calling
instance.exports.on_event(ptr) on any async event (SSE message, timer) is the SAME stack as a click handler; it is NOT the missing FFI-callback (Phase 4) gap. Composition, not a new feature.
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 computed shows the count.
- Host (
web/host.js): the generic host + your api_* effect imports (each
does a fetch).
- Build & screenshot to verify.
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