RPC
elements man rpc Read as markdownAn RPC is a server function the browser calls directly, with typed parameters
and return values. The compiler rewrites the browser call site into a network
request and keeps the function body out of the browser bundle. To make one, put
the /** @rpc */ build tag before a function declaration.
Example/** @rpc */ export function createUser(form: UserForm): User { return sql<User>(` insert into users ( name, email ) values ( ${form.name}, ${form.email} ) returning * `).firstOrThrow("insert returned no row"); }
An RPC function can be declared anywhere in your project, including inside an
.ehtml file. The compiler removes the function body and anything it depends on
from the browser bundle, then rewrites every browser call site into a network
request. The RPC body and its dependencies never reach the browser.
An RPC function is also a security boundary. Server-protected code like sql,
tx, session.login, and email can only run on the server. Browser code cannot
call one: an event handler, a template expression or a function they call that
does is a compile error at the call site. To run protected code from a
template, call an RPC function and put the protected code inside it. Once execution
crosses into the RPC body, the rest of the call stack runs on the server.
Authorization code can be written at the top of an RPC function.
At a Glance
Exampleimport { session, sql, ValidationError } from "@elements/app"; interface UserForm { name: string; email: string; } /** @rpc */ export function createUser(form: UserForm): User { if (!form.email.includes("@")) { throw new ValidationError("invalid email"); } if (!sql(` select 1 from users where email = ${form.email} `).empty()) { throw new ValidationError("email taken"); } return sql<User>(` insert into users ( name, email ) values ( ${form.name}, ${form.email} ) returning * `).firstOrThrow("insert returned no row"); } /** @rpc */ export function getMyProfile(): User { session.isLoggedInOrThrow(); return sql<User>(` select * from users where id = ${session.getOrThrow("userId")} `).firstOrThrow("user not found"); }
Call them from a template like normal functions:
Template<form onsubmit={() => createUser(form)}> <input value={form.name}> <input value={form.email}> </form>
Calls to sql, tx and the other Elements functions that support it need no
await, and neither do calls to an RPC function. The compiler adds the await
and makes every function up the call stack async. man async lists them.
If you're using other libraries that don't participate in the transform, or you
want direct control, mark the RPC async yourself and await what you need.
Elements leaves manual async/await alone.
The Boundary in Practice
Browser code cannot call sql(), tx() or any other server-only function.
The compiler does not allow it. Event handlers, template expressions and the
functions they call all run in the browser, and the only way from there to the
database is an RPC function. Anything else is a compile error at the call site:
app/pages/home/template.ehtml<button onclick={() => sql(`delete from users`)}>delete all</button>
Build errorapp/pages/home/template.ehtml:12:26 Security error: cannot call server code from the browser without going through an rpc function.
The code inside an RPC function stays on the server. Its queries, table names and column names are never sent to the browser.
Cross the boundary by moving the call into an RPC function:
Example/** @rpc */ function deleteAllUsers() { session.isLoggedInOrThrow(); if (sql(` select 1 from admins where id = ${session.getOrThrow("userId")} `).empty()) { throw new ForbiddenError(); } sql(`delete from users`); }
app/pages/home/template.ehtml<button onclick={() => deleteAllUsers()}>delete all</button>
Once execution crosses into the RPC, the entire downstream call stack is
server-side. sql() works in the RPC body, in helpers it calls, and in helpers
those call. It is impossible to accidentally send sql or other server-only
code to the browser. The check is a compile-time error, not a runtime check.
The Build Tag
@rpc is a build tag. Any comment form works.
Example/** @rpc */ export function a() {} /* @rpc */ export function b() {} // @rpc export function c() {}
The JSDoc form (/** @rpc */) is what you'll see most often in Elements code.
Automatic Async Await
Calls to an RPC function, and to the Elements functions that support it, need no
await. The compiler converts each of those calls to its async variant at build
time and propagates async up the call stack, including across function-expression arguments passed to
schedulers like setTimeout, Listener.on, addEventListener, and
requestAnimationFrame.
Among them are sql, tx, channel.notify, LiveTable mutations,
session.login and session.logout, and calls to your own RPC functions.
man async has the full list.
Examplelet user = createUser(form); setTimeout(() => saveDraft(form), 400); videoStatusListener.on("notify", (msg) => listVideo(msg));
The code reads as plain statements and runs async underneath.
If your RPC calls a library that doesn't participate in the transform (the
Stripe SDK, fetch, navigator.clipboard, fs/promises, child_process), or
you want direct control, mark the RPC async yourself and await what you
need. Elements leaves your manual async/await alone.
For explicit concurrency, use the xAsync variants with Promise.all:
Examplelet [users, posts] = await Promise.all([ sqlAsync(`select * from users`), sqlAsync(`select * from posts`), ]);
Authorization
Add authorization checks at the top of every RPC function. The session global
is available in any RPC body and identifies the calling user.
For a simple logged-in gate, call session.isLoggedInOrThrow(). It throws
AuthError (401) when there is no session. For data-aware checks (ownership,
roles), run the query yourself and throw an AuthError or ForbiddenError when
it fails.
For input validation, throw ValidationError directly. The constructor takes
either a plain message (single-rule guard) or a FieldErrors<T> map (per-field
map that survives the RPC boundary intact, so the browser can
errors = err.errors and re-render inline).
Exampleimport { session, sql, AuthError, ValidationError, FieldErrors, } from "@elements/app"; /** @rpc */ export function deletePost(postId: string) { session.isLoggedInOrThrow(); if (sql(` select 1 from posts where id = ${postId} and authorId = ${session.getOrThrow("userId")} `).empty()) { throw new AuthError(); } sql(` delete from posts where id = ${postId} `); } /** @rpc */ export function submitOrder(form: OrderForm) { if (!(form.qty > 0)) { throw new ValidationError("qty must be positive"); } if (form.items.length === 0) { throw new ValidationError("at least one item required"); } // ... } interface SignupForm { email: string; password: string; } /** @rpc */ export function signup(form: SignupForm) { let errors: FieldErrors<SignupForm> = {}; if (!form.email.includes("@")) { errors.email = ["must be a valid email"]; } if (form.password.length < 8) { errors.password = ["must be at least 8 characters"]; } if (Object.keys(errors).length > 0) { throw new ValidationError(errors); } // ... }
ValidationError returns 422 (Unprocessable Entity) and ships its errors
field across the RPC boundary so the browser sees the structured map intact.
session.login() and session.logout() are server-only. Call them from an RPC
body, a route handler, or any helper reachable from one of those. Calling them
from browser code, such as an event handler, is a compile error.
Example/** @rpc */ export function signinAs(token: string) { let user = sql<User>(` select * from users where loginToken = ${token} `).firstOrThrow(new AuthError("invalid token")); session.login({ userId: user.id, userName: user.name, }); }
Full session details: session.
Errors
All app-level errors extend AppError. They are marked safe = true, which
means their messages reach the client. Internal or unsafe errors are wrapped in
ServerError before being sent, so internal details never leak.
AuthError(401): unauthenticated.ForbiddenError(403): authenticated but not permitted.NotFoundError(404): resource missing.ValidationError(422): invalid input.NotAcceptableError(406): request body unparseable.PartitionMismatchError(400): a LiveTable row outside the view's partition.ServerError(500): safe wrapper for internal errors.
Throw them directly when needed. For a missing row, firstOrThrow throws
the NotFoundError for you, with your message:
Exampleimport { session, sql, ForbiddenError } from "@elements/app"; /** @rpc */ export function getRoom(id: string): Room { let room = sql<Room>(` select * from rooms where id = ${id} `).firstOrThrow(`room ${id} not found`); if (room.private && !session.isLoggedIn()) { throw new ForbiddenError(); } return room; }
isSafeError(err) and toSafeError(err) are available for explicit handling.
Catching Errors on the Client
Errors from RPC calls surface in the browser as the same class. Catch them sync-style:
Exampleimport { AuthError, ValidationError, redirect } from "@elements/app"; function onClickSubmit(form: UserForm) { try { let user = createUser(form); flash(`created ${user.name}`); } catch (err) { if (err instanceof ValidationError) { formError = err.message; } else if (err instanceof AuthError) { redirect("/login"); } else { formError = "something went wrong"; } } }
Unsafe server errors arrive as ServerError with a generic message. The
original message is logged on the server but never sent.
RPC and Routing
Usually you render a page with all the data it needs from the route handler. The handler gathers the data, builds the HTML template with that data, and Elements server-renders it on the first request.
In some cases you want the page to load quickly without waiting on data. Render with empty parameters from the route, then call an RPC function from the template to fetch the data after the page attaches.
app/pages/dashboard/index.tsroute handlerimport { Request, Response } from "@elements/app"; import dashboard from "./template"; export default function route(req: Request, res: Response) { return new dashboard({ stats: undefined }); }
app/pages/dashboard/services.tsrpcimport { session, sql } from "@elements/app"; /** @rpc */ export function loadStats(): Stats { session.isLoggedInOrThrow(); return sql<Stats>(` select activeUsers, revenue from dashboardStats `).firstOrThrow("stats unavailable"); }
In the template, wire the RPC call to the root tag's oninsert lifecycle
attribute and assign the result to a template attribute:
app/pages/dashboard/template.ehtml<html (stats?: Stats) oninsert={() => stats = loadStats()}> <p e:if={!stats}>loading…</p> <div e:if={stats}> <span>{stats.activeUsers} active</span> <span>{stats.revenue} revenue</span> </div> </html>
The page renders immediately with stats undefined. Once the RPC returns,
assigning stats triggers the reactive update and the loaded branch renders.
Redirecting from an RPC
redirect(url) works inside an rpc. An RPC arrives over the websocket rather
than as a request of its own, so there is no HTTP response to carry a 302; the
target rides back on the reply and the browser navigates once the call settles.
app/pages/signup/services.tsimport { redirect, session, sql, ValidationError } from "@elements/app"; /** @rpc */ export function signup(form: SignupForm) { if (form.email.trim() === "") { throw new ValidationError({ email: ["required"] }); } let id = sql<{ id: string }>( ` insert into users ( email ) values ( ${form.email} ) returning id ` ).firstOrThrow("signup failed").id; session.login({ userId: id }); redirect("/"); }
Redirect from the RPC. Everything the RPC wrote is committed by the time the
reply is sent, so the navigation always follows the write. For navigation from
browser code after a LiveTable write, see resetUI in
elements man livetable/mutations.
redirect() is not control flow. The RPC runs to completion and its return
value still reaches the caller. Return early if you want the rest skipped.
Returning Channels and Live Views
RPC parameters and return values cross the wire through the Elements JSON serializer, preserving class identity, handling object cycles, and serializing live views.
An RPC function can return a Channel listener or a LiveView as data. The
browser receives a live, subscribed handle. Updates flow over the WebSocket.
Example/** @rpc */ export function watchRoom(roomId: string) { if (sql(` select 1 from roomMembers where roomId = ${roomId} and userId = ${session.getOrThrow("userId")} `).empty()) { throw new AuthError(); } return roomMessages.view({ roomId }); }
In the template:
Template<ul> <li e:for={msg of messages}>{msg.text}</li> </ul>
Where messages is the LiveView returned by watchRoom.
Related
router: route handlers, request/response, the page-render flow.database:sql(),tx(), the camelCase boundary, and the sync-to-async transform.session:session.login,session.get, sliding-window sessions.json: the Elements JSON serializer that RPC parameters and returns ride on.livetable: auto-rpc select/insert/update/delete with optimistic UI and realtime.channel: lower-level pub/sub.