agentsclimarketplace

Machin web

Skill javimosch/machin/plugins/machin/skills/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

Install
npx -y skills add javimosch/machin --skill machin-web

Assembled 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 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).

callwhat it does
signal(v) -> ida state cell; get(id) reads (auto-tracks a dependency), set(id, v) writes (notifies dependents if changed)
computed(func(){ return … }) -> ida memoized derived signal; read with get like any signal
slot(name, compute) -> markupmarkup for a reactive text node (<span data-s=name>) + queues its binding
list(name, keys, item) -> markupmarkup 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 no main.
  • 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 with ptr_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)

  • 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)

  1. Schema (models.src, shared): a user_row(id, name, email) -> html used by both SSR and the client list item.
  2. 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))) }
          }
      }
      
  3. 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.
  4. Host (web/host.js): the generic host + your api_* effect imports (each does a fetch).
  5. 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

Keep looking

Skills are one crate of 628,853. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.