Manual RPC

RPC

elements man rpc Read as markdown

An 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

Example
import { 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 error
app/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.

Example
let 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:

Example
let [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).

Example
import { 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:

Example
import { 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:

Example
import { 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 handler
import { 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.tsrpc
import { 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.ts
import { 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.