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 # the SPA
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 # the binary
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")) } // serve your own SPA
if has_prefix(req.path, "/api/users") { return ok_json(users_json()) }
return ok_html(page()) // SSR
}
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
machin-tinystats (a 71 KB metrics
dashboard: HTTP + WS + hub broadcast + a getState chan chan string for REST endpoints
that need the current state synchronously without waiting for the next broadcast).
- 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 typed []User to range over — values decoded
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) {
// ...check credentials...
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", "") /* or redirect to /login */ }
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). 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(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); } },
// …your app's effect imports, e.g. api_vote, focus_input…
};
const wasi = { fd_write:()=>0, fd_seek:()=>0, fd_close:()=>0, fd_fdstat_get:()=>0 }; // no-op shim (see gotchas)
const { instance } = await WebAssembly.instantiateStreaming(fetch('/app.wasm'), { env, wasi_snapshot_preview1: wasi });
mem = instance.exports.memory; instance.exports._initialize?.();
instance.exports.start();
// write a string IN: const b=enc.encode(text); const p=Number(instance.exports.input_buf(BigInt(b.length))); new Uint8Array(mem.buffer).set(b,p); instance.exports.submit(BigInt(p));
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:
// client.src — declare the imports to RETURN a value (never before exercised in a demo)
extern "env" {
fn http_get(string) string // returns response body as a string POINTER
fn http_post(string, string) string // (url, body) -> response body
}
// MUST export a buffer allocator: `alloc` is a builtin and is NOT exported by default,
// so the JS host has nowhere to write the return string. Without this, returning
// effect imports crash at runtime (instance.exports.alloc is not a function).
export func alloc_export(n) (p) { p = alloc(n) }
func fetch_page(slug) {
page_str = http_get("/api/page/" + slug) // MFL drives the call, uses result inline
set(ver, get(ver) + 1) // int version signal bumps → outlet re-renders
}
// host.js — the effect import writes the response into wasm memory and returns the pointer
http_get: (urlPtr) => {
const b = enc.encode(cstr(urlPtr)); // sync XHR
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))); // ← the added export
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) { // body: "a=1&b=2"; key: "a"
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
1---2name: machin-web3description: Building web apps in machin4---56# Building web apps in machin78machin compiles MFL to a **native binary** (the server) *and* to **WebAssembly**9(the browser client). One language does both ends of the wire — server, API, SSR10HTML, and a fine-grained reactive UI — with **no Node, no bundler, no11`node_modules`**. The JS host is a fixed ~30 lines; everything else is MFL.1213> Run `machin guide` first — it's the version-exact language catalog (builtins,14> idioms, gotchas), including a `reactive-runtime` note. This skill is the *web how-to*.1516## The fastest path: start from the boilerplate1718[`boilerplate-cli-ui-machin-isomorphic`](https://github.com/javimosch/boilerplate-cli-ui-machin-isomorphic)19is a working single binary that is a **CLI + HTTP server + JSON API + reactive wasm20UI** (SSR + hydration, shared model). Clone it and edit — it already wires up21everything below. To build something new, copy its structure:2223```24src/machweb.src src/flags.src src/reactive.src # vendored frameworks (from machin/framework)25src/models.src # shared schema/render, compiled into BOTH server and client26src/client.src # the wasm UI (reactive)27src/server.src # machweb routes + CLI main; serves its own wasm + the API28web/host.js # the generic JS host, embedded into the binary at build time29build.sh30```3132`build.sh` does two compiles: the client to wasm, then the server (which embeds33`web/host.js` and serves `app.wasm`):3435```sh36machin encode src/reactive.src src/models.src src/client.src > client.mfl37machin build client.mfl --target wasm -o app.wasm # the SPA38python3 -c "import json;print('func host_js() (s) { s = '+json.dumps(open('web/host.js').read())+' }')" > src/host_gen.src39machin encode src/machweb.src src/flags.src src/models.src src/server.src src/host_gen.src > server.mfl40machin build server.mfl -o app # the binary41```4243Needs `machin` (v0.57.0+ for forms) and [`zig`](https://ziglang.org) (the C→wasm44compiler — a single binary, no emscripten). The frameworks live in45[`machin/framework`](../../framework); vendor copies into your repo.4647## The server — `framework/machweb.src`4849A handler is `func(Request) Response`. `serve(port, handler)` runs it (one50goroutine per connection). `req.method` / `req.path` (path carries the query51string) / `req.body` / `header(req, name)` / `cookie(req, name)`.5253Response builders: `ok_text` · `ok_html` · `ok_json` · `ok_bytes(ctype, b)` ·54**`ok_wasm(b)`** · `created` · `bad_request` · `not_found`. Add any header with55`with_header(res, name, value)` (e.g. `Content-Disposition` for a download filename).5657```go58func handle(req) (res) {59 if req.path == "/app.wasm" { return ok_wasm(read_file_bytes("app.wasm")) } // serve your own SPA60 if has_prefix(req.path, "/api/users") { return ok_json(users_json()) }61 return ok_html(page()) // SSR62}63func main() { serve(48080, func(req) { return handle(req) }) }64```6566- **Serve your own wasm:** `ok_wasm(read_file_bytes("app.wasm"))` — `read_file_bytes`67 is NUL-safe (a `.wasm` has NUL bytes a string body would truncate).68- **File uploads (`multipart/form-data`):** requests are read binary-safe, so a browser69 file upload survives intact. `multipart_file(req, "file")` → `(part, ok)` with70 `part.filename` / `part.ctype` / `part.data` (raw bytes — store with71 `write_file_bytes`); `multipart_field(req, "name")` reads a text field of the same form.72 `parse_multipart(req)` returns every part. The raw binary body is `req.body_bytes`. See73 [machin-share](https://github.com/javimosch/machin-share).74- **Streaming / Server-Sent Events:** return `sse(func(conn) { ... })` instead of a whole75 Response and write events over the open socket with `sse_data(conn, msg)` /76 `sse_event(conn, name, msg)` / `sse_comment(conn, ping)` — for live logs, progress, or77 LLM tokens. A write returns `< 0` once the client disconnects (break the loop). SIGPIPE78 is ignored so a vanished client can't kill the server. For cross-goroutine fan-out79 (one producer → many live subscribers) use a single hub goroutine that owns the80 subscriber set (a `chan` field inside a struct, a `[]Sub` registry) — see81 [machin-live](https://github.com/javimosch/machin-live).82- **WebSockets (`framework/ws.src`):** compose `ws.src` after `machweb.src`, then a handler83 returns `ws(req, func(c) { ... })`. machweb does the upgrade (via the generic84 `hijack(fn)` response) and `c` is a `WSConn`: `ws_next_text(c)` → `(msg, ok)` reads the85 next message (answering pings, stopping on close); `ws_send_text(c.fd, s)` /86 `ws_send_bytes(c.fd, b)` send. A connection that also receives broadcasts wants **two87 goroutines** — the handler loop reads, a spawned `go writer(c.fd, ch)` drains an outbound88 channel (read + write are independent on the fd). Fan-out is the same hub-goroutine89 pattern as SSE. See [machin-rooms](https://github.com/javimosch/machin-rooms) and90 [machin-tinystats](https://github.com/javimosch/machin-tinystats) (a 71 KB metrics91 dashboard: HTTP + WS + hub broadcast + a `getState chan chan string` for REST endpoints92 that need the current state synchronously without waiting for the next broadcast).93- **A database:** machin has SQLite builtins — `sqlite_open(path)` / `sqlite_exec(db,94 sql)` / `sqlite_exec(db, sql, []string params)` (`?`-bind, injection-safe) /95 `sqlite_query(db, sql[, params]) -> string` (a **JSON-array-of-rows string**) /96 `sqlite_close(db)`. Perfect for a users table behind a JSON API. **Decode rows with97 `parse`, not `json_get`:**98 ```go99 type User struct { id int name string email string }100 rows := sqlite_query(db, "SELECT id, name, email FROM users ORDER BY id")101 users := parse(rows, []User{}) // a typed []User to range over — values decoded102 ```103 A **slice witness** (`[]User{}`) is the row-iteration idiom. Reach for `json_get(rows,104 "[0].name")` only to pull ONE field — and note **it returns the raw JSON token, so a105 string comes back quoted** (`"Ada"`); strip the quotes (`substr(s, 1, len(s)-1)`) or106 just use `parse`. Always parameterize (`?`, `[]string{...}`); never concat user input.107- **A networked database** (shared across instances): machin has pure-MFL drivers for108 **PostgreSQL**, **MySQL/MariaDB**, **Redis**, and **MongoDB** (all connection-pooled,109 no cgo) — each returns rows as JSON the same way, so `parse(rows, []T{})` decodes them.110 See the `postgres-client` / `mysql-client` / `mongo-client` / `redis-client` /111 `machweb-sessions` / `sso-oauth` idioms in `machin guide`, and112 [`docs/NORTH-STAR-BACKEND.md`](../../docs/NORTH-STAR-BACKEND.md) for the full backend113 story (sessions, SSO, pooling, and the worked MachNotes / machin-cms apps).114- **A CLI:** compose `framework/flags.src` — `new_flags` / `flag_int` / `flag_str` /115 `flag_bool` / `parse_flags(fs, args())` / `flag_int_val` / `flag_on(fs,"help")`116 (auto `--help`).117- **Cookies & login sessions:** `cookie(req, name)` reads a request cookie;118 `set_cookie(res, name, value)` / `clear_cookie(res, name)` return a `Response` with119 a `Set-Cookie` (safe defaults: `Path=/; HttpOnly; SameSite=Lax`). For login, use a120 **signed** session the client can't forge: `set_session(res, secret, "sid", userID)`121 stores `userID` + an HMAC tag, and `get_session(req, secret, "sid") -> (value, ok)`122 returns `ok == 1` only if it verifies. `Response` is a value, so chain the helper:123 ```go124 func login(req) (res) {125 // ...check credentials...126 res = set_session(ok_html(dashboard()), secret, "sid", "user:42")127 }128 func page(req) (res) {129 uid, ok := get_session(req, secret, "sid")130 if ok == 0 { return set_cookie(not_found(), "x", "") /* or redirect to /login */ }131 res = ok_html(home(uid))132 }133 ```134 Keep `secret` server-side (e.g. `env("SESSION_SECRET")`). The value is signed, not135 encrypted — store an id/handle, not secrets.136- **SSO — "log in with Google/Microsoft/…"** (`framework/sso.src`, OAuth2 + OIDC). Fill137 an `OAuthProvider{auth_url, token_url, userinfo_url, client_id, client_secret,138 redirect_uri, scope}`, then two routes: `GET /login` → `sso_begin(p, secret)` (302 to139 the provider + a signed `oauth_state` cookie for CSRF); `GET /callback` →140 `profile, ok := sso_complete(p, secret, req)` (verifies state, exchanges the code,141 fetches the userinfo JSON). On `ok` read `json_get(profile, ".sub")`/`".email"` and142 `set_session(...)` your own login cookie, then `redirect("/")`. Identity comes from the143 provider's **userinfo endpoint** (so no JWT/RSA needed). Also uses `redirect(url)` and144 `query(req, name)` (now in machweb). See `examples`/the SSO test for the full flow.145146## The client — `framework/reactive.src` (signals + a patch list)147148The Solid/Leptos model in MFL: state in **signals**, fine-grained updates. Only the149reactions that read a changed signal recompute, and only changed text/keys touch150the DOM (no `innerHTML` churn, no vdom diff).151152| call | what it does |153|---|---|154| `signal(v) -> id` | a state cell; `get(id)` reads (auto-tracks a dependency), `set(id, v)` writes (notifies dependents if changed) |155| `computed(func(){ return … }) -> id` | a memoized derived signal; read with `get` like any signal |156| `slot(name, compute) -> markup` | markup for a reactive text node (`<span data-s=name>`) + queues its binding |157| `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 |158| `mount(root, html)` | set the root's innerHTML once, then activate the queued slots/lists (client-side render) |159| `hydrate(html)` | activate them against an **already-SSR'd DOM** (no re-render) — for isomorphic pages |160161**Multi-page (`framework/router.src`).** For an admin app with several pages, compose162the router too. The active route is a signal (an int index): `router_init(0)`,163`route(path)` registers pages in order, `link(path, label)` renders a nav anchor,164`current_route()` reads the active index, and `outlet(id, render)` re-renders the165active page (a reaction over a `dom_html` host import) and syncs the address bar.166`page()` switches on `current_route()` and must be a **pure render** (read signals,167never `set` — that loops). `path_index(path)` returns **-1** for an unregistered168path, so a `page()` can render a real 404 instead of silently showing route 0;169`navigate` ignores an out-of-range index (either end), so an unknown link is inert170rather than a crash. `router_init` also **clears** the route table, so calling it171again gives a clean slate. The host adds `dom_html`/`nav_url`, forwards `[data-nav]`172clicks to `nav(path)` (path in via `ptr_str`) + `popstate`, and a catch-all server173serves the shell for any path (deep-links). See [machin-web-demo-router](https://github.com/javimosch/machin-web-demo-router).174175A whole component is one expression:176177```go178export func start() {179 n = signal(0)180 total = computed(func() { get(ver) return sum_of(items) })181 mount("app",182 "<h1>Items</h1>" +183 slot("total", func() { return str(get(total)) }) +184 list("items", func() { get(ver) return csv(ids) }, func(id) { return row(id) }))185}186```187188The host supplies five DOM ops as imports: `dom_mount` · `dom_patch` ·189`list_insert` · `list_remove` · `list_order` (see the host below).190191**Keyed lists only re-render an item on INSERT**, not when a kept item's content192changes. To make a row update when its data changes, **encode the mutable state in193the key** (`key = id*100 + done`); a change makes a new key → one remove+insert.194195## The wasm bridge & marshaling196197- **JS → machin:** `export func name(...)` becomes a wasm export the host calls198 (`instance.exports.name`). A wasm module needs no `main`.199- **machin → JS:** a headerless `extern "env" { fn dom_patch(string, string) }`200 becomes a wasm import the host supplies.201- **ints** cross both ways as **`BigInt`** (machin ints are i64).202- **strings out:** machin returns a pointer into `memory`; the host decodes203 NUL-terminated UTF-8 (`let e=p; while(b[e])e++; TextDecoder().decode(b.subarray(p,e))`).204- **strings in (forms):** the host writes UTF-8 into a buffer the module `alloc`'d,205 then machin reads it with **`ptr_str(ptr)`** (v0.57.0):206 ```go207 export func input_buf(n) (p) { p = alloc(n + 1) } // host writes n bytes + NUL here208 export func submit(p) { let t = ptr_str(p) free(p) /* …use t… */ }209 ```210211## The generic JS host (reusable as-is)212213```js214let mem; const dec = new TextDecoder(), enc = new TextEncoder();215const cstr = p => { const b = new Uint8Array(mem.buffer); let e=p; while(b[e])e++; return dec.decode(b.subarray(p,e)); };216const env = {217 dom_mount: (r,h) => { document.getElementById(cstr(r)).innerHTML = cstr(h); },218 dom_patch: (s,v) => { const el = document.querySelector('[data-s="'+cstr(s)+'"]'); if (el) el.textContent = cstr(v); },219 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); },220 list_remove:(c,k) => { const el=document.querySelector('#'+cstr(c)+' > [data-k="'+cstr(k)+'"]'); if (el) el.remove(); },221 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); } },222 // …your app's effect imports, e.g. api_vote, focus_input…223};224const wasi = { fd_write:()=>0, fd_seek:()=>0, fd_close:()=>0, fd_fdstat_get:()=>0 }; // no-op shim (see gotchas)225const { instance } = await WebAssembly.instantiateStreaming(fetch('/app.wasm'), { env, wasi_snapshot_preview1: wasi });226mem = instance.exports.memory; instance.exports._initialize?.();227instance.exports.start();228// write a string IN: const b=enc.encode(text); const p=Number(instance.exports.input_buf(BigInt(b.length))); new Uint8Array(mem.buffer).set(b,p); instance.exports.submit(BigInt(p));229```230231## Build & verify (this environment)232233- `zig` is on PATH (`/snap/bin/zig`); `machin build --target wasm` uses it.234- Serve over **http** (`python3 -m http.server`) — wasm won't load from `file://`.235- **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.236- 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.237238## Gotchas (hard-won)239240- **No-op WASI stub:** the reactive runtime's indirect closure calls keep wasi-libc's241 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.242- **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.)243- **Lambdas have no named returns:** `func() (s) { s = x }` doesn't parse — use `func() { return x }`.244- **Function scope, not block scope:** a variable used as two types across `if` branches conflicts; give each branch its own name.245- **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.246- **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.247- **Two `extern "env"` blocks compose fine** (the runtime's DOM ops + your app's effect imports).248- **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.249- **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).250- **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)]`.251- **`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{})`.252- **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.253- **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.254- **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`.255- **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.256- **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.257258## Returning effect imports (HTTP from the wasm client)259260Existing demos never let the WASM client *initiate* a server call — JS caught the261click, did the `fetch`, and fed the result back into MFL via a `load()` export. **Returning262`extern "env"` imports invert that**: MFL says `data := http_get(url)` and uses the263result inline (a synchronous XHR blocks under wasm). Two things must line up:264265```go266// client.src — declare the imports to RETURN a value (never before exercised in a demo)267extern "env" {268 fn http_get(string) string // returns response body as a string POINTER269 fn http_post(string, string) string // (url, body) -> response body270}271272// MUST export a buffer allocator: `alloc` is a builtin and is NOT exported by default,273// so the JS host has nowhere to write the return string. Without this, returning274// effect imports crash at runtime (instance.exports.alloc is not a function).275export func alloc_export(n) (p) { p = alloc(n) }276277func fetch_page(slug) {278 page_str = http_get("/api/page/" + slug) // MFL drives the call, uses result inline279 set(ver, get(ver) + 1) // int version signal bumps → outlet re-renders280}281```282```js283// host.js — the effect import writes the response into wasm memory and returns the pointer284http_get: (urlPtr) => {285 const b = enc.encode(cstr(urlPtr)); // sync XHR286 const xhr = new XMLHttpRequest(); xhr.open('GET', cstr(urlPtr), false); xhr.send();287 const r = enc.encode(xhr.responseText + '\0');288 const p = Number(instance.exports.alloc_export(BigInt(r.length))); // ← the added export289 new Uint8Array(mem.buffer).set(r, p); return p;290},291```292This is the enabler for a wasm SPA that talks to its own server API — and the moment293enough endpoints are hand-bridged, the boilerplate motivates a `machin gen-client`294(typed RPC, the next north-star gap).295296## Recipe: a CRUD back-office (e.g. manage a users DB)2972981. **Schema** (`models.src`, shared): a `user_row(id, name, email) -> html` used by299 both SSR and the client list item.3002. **Server** (`server.src`): `sqlite_open("users.db")`, create the table; routes —301 `GET /` SSR-renders the user list (matching `data-s` names so the client302 hydrates), `GET /app.wasm` serves the client, `GET /api/users` returns rows as303 JSON (`ok_json(json(parse(sqlite_query(...), []User{})))` or just the raw query304 string), `POST /api/users` inserts (parse the body), `DELETE`/`POST /api/users/del`305 removes. Use parameterized `sqlite_exec(db, sql, params)` — never string-concat SQL.306 - **The POST body shape depends on the sender.** A `fetch` with a JSON body →307 `parse(req.body, User{})` or `json_get`. A browser `<form>` (non-JS fallback, or308 `Content-Type: application/x-www-form-urlencoded`) → the body is `name=Ada&email=a%40b`;309 decode each field with the **`url_decode` builtin** (don't hand-roll it — a function310 named `url_decode` is a compile error, it shadows the builtin). A tiny helper:311 ```go312 func form_field(body, key) (v) { // body: "a=1&b=2"; key: "a"313 for _, kv := range split(body, "&") {314 eq := index(kv, "=")315 if eq > 0 && substr(kv, 0, eq) == key { v = url_decode(substr(kv, eq+1, len(kv))) }316 }317 }318 ```3193. **Client** (`client.src`): signals hold the rows; `each` renders the table320 (re-key on any mutable field); a **form** (`<input>` + `ptr_str`) adds a user and321 `POST`s; row buttons toggle/delete and call the API. A `computed` shows the count.3224. **Host** (`web/host.js`): the generic host + your `api_*` effect imports (each323 does a `fetch`).3245. **Build & screenshot** to verify.325326The state lives in machin; the server is the source of truth (SQLite); the client is327reactive over the API. One binary, one language.328329> Worked implementation: [machin-web-demo-users](https://github.com/javimosch/machin-web-demo-users) — the exact app above, end to end. (For the *isomorphic* shape instead, see the boilerplate.)330331## Pointers332333- Runnable in-tree: [`examples/complex/sqlite_crud.mfl`](../../examples/complex/sqlite_crud.mfl) — the SQLite data layer end to end (open → parameterized insert → `parse([]User{})` → update/delete → JSON), no server, runs under `machin run`.334- Frameworks: [`framework/machweb.src`](../../framework) · `reactive.src` · `router.src` · `flags.src`.335- Boilerplate: [boilerplate-cli-ui-machin-isomorphic](https://github.com/javimosch/boilerplate-cli-ui-machin-isomorphic).336- Focused demos: [machin-web-demo-wasm](https://github.com/javimosch/machin-web-demo-wasm) (the bridge) · [machin-web-demo-ssr](https://github.com/javimosch/machin-web-demo-ssr) (isomorphic SSR) · [machin-web-demo-reactive](https://github.com/javimosch/machin-web-demo-reactive) (signals/computed/lists/templating) · [machin-web-demo-todo](https://github.com/javimosch/machin-web-demo-todo) (forms / text input).337- Direction & the dogfood record: [`docs/NORTH-STAR-WEB.md`](../../docs/NORTH-STAR-WEB.md).338- The language itself: `machin guide`.