TL;DR — Mark an
async fnwith@rpcand call it from a.fitzv
component as if it were local:let u = get_user(42).await?. The
compiler generates both halves from that one declaration — a
POST /__rpc/get_userendpoint on the server (which can touch the DB,
auth, secrets) and afetch-based stub on the client — with the same
typeshared back and front. No hand-written HTTP handler, no
fetch, no JSON glue, no route strings, no external deps. (Part 7 of
the FitzLiveViews series — the "whole stack, no plumbing" moment.)
Earlier parts showed a .fitzv component compiling two ways: to
server-rendered HTML (LiveViews over a WebSocket) and to a standalone
WebAssembly SPA. Both are great — but a component in the browser is an
island. Anything real — reading the database, checking a token — has
to happen on the server, and the classic bridge is plumbing: write an
endpoint, write a fetch, marshal the JSON both ways, and keep the
types in sync by hand.
This part erases all of that with one decorator.
One declaration, both halves
Here's a server function. It's a normal async fn — it could open a
Postgres connection, run an ORM query, verify a JWT. The only special
thing is @rpc:
// api.fitz (classic — has db, auth, secrets)
type User {
id: Int
name: Str
}
@rpc
async fn get_user(id: Int) -> Result<User> {
let conn = db.connect(db_url()).await?
return User.where(fn(u) => u.id == id).first(conn).await
}
And here's the client calling it, inside a .fitzv component's event
handler:
// App.fitzv (client-WASM)
from api import get_user, User
component App {
state { name: Str = "" }
event load() {
let u = get_user(42).await? // ← a round-trip, typed, like a local call
name = u.name
}
<template>
<p>{name}</p>
<button @click="load">Load user 42</button>
</template>
}
That's the whole app. No fetch. No /api/users/:id. No
JSON.parse + a hand-written User interface that drifts from the
server. The User type lives once in api.fitz; the server
compiles it into a native binary, the client compiles it into WASM, and
they share the definition.
What the compiler actually emits
@rpc is invisible-but-typed because the compiler writes both sides for
you.
Server side (fitz build --bin server): the @rpc fn is mounted as
POST /__rpc/get_user. The request body is a JSON object with one field
per parameter ({"id": 42}); each param is deserialized from its field,
the function runs, and its Result<T> becomes 200 + the JSON of T
(Ok) or 500 + {"error": ...} (Err). It reuses the entire @post
pipeline — observability, panic-catch, everything.
Client side (fitz build --bin web --target wasm-client): the
imported @rpc fn is emitted as an async fetch stub — its
(server-side) body is not transpiled into the WASM. The stub
serializes the args, POSTs same-origin (so the session cookie rides
along), and maps the reply back to Result<T>. And because the event
handler now .awaits, the compiler splits it into a sync wrapper + an
async worker (spawn_local), so state updates and a re-render fire when
the reply lands.
You write get_user(42).await?. You get a network round-trip with a
typed result.
It really runs
The runnable example
is a two-button SPA plus a server binary. Verified end-to-end in real
Chrome: click "Greet" and the greeting comes back from the server
("Hello, world!"); click "Load user" and the User is fetched,
deserialized, and rendered ("Ada") — zero page errors. The client
crate compiles to a real .wasm via wasm-pack; the server answers the
exact /__rpc/* routes.
fitz build --bin server # mounts POST /__rpc/* on :3838
fitz build --bin web # → target/wasm/web/{web.js, web_bg.wasm}
Serve the SPA from the same origin as the server (in production,
have the server serve the bundle, or put both behind one reverse proxy)
so the relative fetch("/__rpc/...") reaches it.
Why this is different
Server functions aren't new — Next.js Server Actions, Remix
loaders/actions, SvelteKit +page.server, tRPC, Phoenix all have the
pattern. What's different in Fitz:
-
No infrastructure step. No
"use server"directive, no tRPC router, no code generator. One decorator; the compiler does the rest. -
The same
type, literally. Not "inferred types" across a boundary — the exact sameUserdefinition compiled to both a native struct and a WASM struct. Zero drift, by construction. -
Zero external deps. The server half reuses the built-in HTTP
stack; the client half pulls in
fetch+serdeonly when a crate actually uses@rpc. No@trpc/*, no bundler magic. - One language, both ends. The server fn and the client component are the same Fitz, checked by the same checker.
The honest edges (MVP)
- A nominal type that crosses the wire is imported into the
.fitzvtoo (from api import ..., User) so the client gets its struct. - Stacked auth on the generated endpoint (
@authenticated/@admin) is a post-MVP refinement; for now the same-origin session cookie travels with the request and you check the token inside the@rpcbody. - The component re-renders once, when the reply arrives — a mid-request "loading…" flash is a later fine-grained-reactivity slice.
That's the fullstack loop closed: a component in the browser calling a
function on the server, typed end-to-end, with nothing in between that
you had to write. Next up: SSR hydration — the same .fitzv rendered on
the server for first paint, then the WASM runtime taking over the
existing DOM (including the state your @rpc calls produced).
Top comments (0)