Machin web
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).From its SKILL.md
npx -y skills add javimosch/machin --skill machin-webAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 10 stars10 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
23.5 KB, as published. Nobody here has run it
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 guidefirst — it's the version-exact language catalog (builtins, idioms, gotchas), including areactive-runtimenote. 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_bytesis NUL-safe (a.wasmhas 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)withpart.filename/part.ctype/part.data(raw bytes — store withwrite_file_bytes);multipart_field(req, "name")reads a text field of the same form.parse_multipart(req)returns every part. The raw binary body isreq.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 withsse_data(conn, msg)/sse_event(conn, name, msg)/sse_comment(conn, ping)— for live logs, progress, or LLM tokens. A write returns< 0once 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 (achanfield inside a struct, a[]Subregistry) — see machin-live. - WebSockets (
framework/ws.src): composews.srcaftermachweb.src, then a handler returnsws(req, func(c) { ... }). machweb does the upgrade (via the generichijack(fn)response) andcis aWSConn: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 spawnedgo 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 + agetState chan chan stringfor 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 withparse, notjson_get:
A slice witness (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[]User{}) is the row-iteration idiom. Reach forjson_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 useparse. 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 thepostgres-client/mysql-client/mongo-client/redis-client/machweb-sessions/sso-oauthidioms inmachin guide, anddocs/NORTH-STAR-BACKEND.mdfor 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 aResponsewith aSet-Cookie(safe defaults:Path=/; HttpOnly; SameSite=Lax). For login, use a signed session the client can't forge:set_session(res, secret, "sid", userID)storesuserID+ an HMAC tag, andget_session(req, secret, "sid") -> (value, ok)returnsok == 1only if it verifies.Responseis a value, so chain the helper:
Keepfunc 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)) }secretserver-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 anOAuthProvider{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 signedoauth_statecookie for CSRF);GET /callback→profile, ok := sso_complete(p, secret, req)(verifies state, exchanges the code, fetches the userinfo JSON). Onokreadjson_get(profile, ".sub")/".email"andset_session(...)your own login cookie, thenredirect("/"). Identity comes from the provider's userinfo endpoint (so no JWT/RSA needed). Also usesredirect(url)andquery(req, name)(now in machweb). Seeexamples/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
- JS → machin:
export func name(...)becomes a wasm export the host calls (instance.exports.name). A wasm module needs nomain. - machin → JS: a headerless
extern "env" { fn dom_patch(string, string) }becomes a wasm import the host supplies. - ints cross both ways as
BigInt(machin ints are i64). - strings out: machin returns a pointer into
memory; the host decodes NUL-terminated UTF-8 (let e=p; while(b[e])e++; TextDecoder().decode(b.subarray(p,e))). - strings in (forms): the host writes UTF-8 into a buffer the module
alloc'd, then machin reads it withptr_str(ptr)(v0.57.0):export func input_buf(n) (p) { p = alloc(n + 1) } // host writes n bytes + NUL here export func submit(p) { let t = ptr_str(p) free(p) /* …use t… */ }
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)
zigis on PATH (/snap/bin/zig);machin build --target wasmuses it.- Serve over http (
python3 -m http.server) — wasm won't load fromfile://. - 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.wasmwith stub imports that recorddom_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-
snprintfpath, so the wasm importswasi_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. (Anexternmay shadow — that's for FFI.) - Lambdas have no named returns:
func() (s) { s = x }doesn't parse — usefunc() { return x }. - Function scope, not block scope: a variable used as two types across
ifbranches conflicts; give each branch its own name. - Package globals (
var x = 0at 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'ssvalis[]int{}, sosignal("")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 tosqlite_exec/sqlite_queryare[]string{name, str(id)}, never[name, str(id)]. sqlite_queryreturns a JSON ARRAY of rows —{"user":[{...}]}not{"user":{...}}. Andjson_get's path language does NOT composekey[idx].field(key → array index → field) — it returns err. For API responses use typedparse(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) stringcompiles and runs (first exercised by machin-wiki); the JS host returns a pointer into wasm linear memory. BUT theallocbuiltin 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_routerdoes exactMETHOD PATHmatch and 404s client-side routes (/new,/page/slug). Useserve(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 todispatch. - Deep-link testing under headless Chrome —
--dump-dom/--screenshotreturn BEFORE the initial-navigationsetTimeoutfires; pass--virtual-time-budget=2000so 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): auser_row(id, name, email) -> htmlused 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 (matchingdata-snames so the client hydrates),GET /app.wasmserves the client,GET /api/usersreturns rows as JSON (ok_json(json(parse(sqlite_query(...), []User{})))or just the raw query string),POST /api/usersinserts (parse the body),DELETE/POST /api/users/delremoves. Use parameterizedsqlite_exec(db, sql, params)— never string-concat SQL.- The POST body shape depends on the sender. A
fetchwith a JSON body →parse(req.body, User{})orjson_get. A browser<form>(non-JS fallback, orContent-Type: application/x-www-form-urlencoded) → the body isname=Ada&email=a%40b; decode each field with theurl_decodebuiltin (don't hand-roll it — a function namedurl_decodeis 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))) } } }
- The POST body shape depends on the sender. A
- Client (
client.src): signals hold the rows;eachrenders the table (re-key on any mutable field); a form (<input>+ptr_str) adds a user andPOSTs; row buttons toggle/delete and call the API. Acomputedshows the count. - Host (
web/host.js): the generic host + yourapi_*effect imports (each does afetch). - 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
- Runnable in-tree:
examples/complex/sqlite_crud.mfl— the SQLite data layer end to end (open → parameterized insert →parse([]User{})→ update/delete → JSON), no server, runs undermachin run. - Frameworks:
framework/machweb.src·reactive.src·router.src·flags.src. - Boilerplate: boilerplate-cli-ui-machin-isomorphic.
- Focused demos: machin-web-demo-wasm (the bridge) · machin-web-demo-ssr (isomorphic SSR) · machin-web-demo-reactive (signals/computed/lists/templating) · machin-web-demo-todo (forms / text input).
- Direction & the dogfood record:
docs/NORTH-STAR-WEB.md. - The language itself:
machin guide.