# RPC 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. ```ts /** @rpc */ export function createUser(form: UserForm): User { return sql(` 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 html 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. Calling one from browser-reachable code is a compile error pointing at the call site. To run protected code from a template, route it through an rpc function. 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 ```ts 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(` 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(`select * from users where id = ${session.getOrThrow("userId")}`).firstOrThrow("user not found"); } ``` Call them from a template like normal functions: ```html
createUser(form)}>
``` You don't need `async` or `await` when an rpc uses Elements standard library functions like `sql` and `tx`. The entire call stack is converted to async automatically. 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 Calling server-protected code from browser-reachable code is a compile error pointing at the call site: ```ts // app/pages/home/template.html // ^^^ compile error: sql() is server-only ``` Cross the boundary by moving the call into an rpc function: ```ts /** @rpc */ function deleteAllUsers() { session.isLoggedInOrThrow(); if (sql(`select 1 from admins where id = ${session.getOrThrow("userId")}`).empty()) { throw new ForbiddenError(); } sql(`delete from users`); } ``` ```html ``` 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. ```ts /** @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 You don't need to write `async` or `await` unless you want to. By default, Elements handles it for you. The compiler converts calls to Elements standard library functions to their async variants 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`. The functions that participate in this transform are `sql`, `tx`, `Channel.notify`, `LiveTable` mutations, `session.login` / `logout` / `renew`, and calls to your own rpc functions. ```ts 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`: ```ts 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` map (per-field map that survives the rpc boundary intact, so the browser can `errors = err.errors` and re-render inline). ```ts 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 = {}; 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-reachable code is a compile error. ```ts /** @rpc */ export function loginAs(token: string) { let user = sql(`select * from users where loginToken = ${token}`).first(); if (!user) { throw 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. - `ScopeMismatchError` (400): LiveTable scope mismatch. - `ServerError` (500): safe wrapper for internal errors. Throw them directly when needed: ```ts import { session, sql, NotFoundError, ForbiddenError } from "@elements/app"; /** @rpc */ export function getRoom(id: string): Room { let room = sql(`select * from rooms where id = ${id}`).first(); if (!room) { throw new NotFoundError(`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: ```ts 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. ```ts // app/pages/dashboard/index.ts (route handler) import dashboard from "./template"; export default function route(req, res) { return new dashboard({ stats: undefined }); } ``` ```ts // app/pages/dashboard/services.ts (rpc) import { session, sql } from "@elements/app"; /** @rpc */ export function loadStats(): Stats { session.isLoggedInOrThrow(); return sql(`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: ```html stats = loadStats()}>

loading…

{stats.activeUsers} active {stats.revenue} revenue
``` 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. ```ts // 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("/"); } ``` Redirecting here rather than from the browser is the safer half of the pattern. Everything the rpc wrote is committed by the time the reply is sent, so there is no way for the navigation to outrun the write. A redirect fired from a browser callback that runs *before* the write is dispatched can lose it silently; see the `resetUI` warning in `elements man livetable`. `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 Tables Rpc parameters and return values cross the wire through the Elements json serializer, preserving class identity, handling object cycles, and serializing live tables. An rpc function can return a `Channel` listener or a `LiveTable` as data. The browser receives a live, subscribed handle. Updates flow over the WebSocket. ```ts /** @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.scope(roomId); } ``` In the template: ```html
  • {msg.text}
``` Where `messages` is the LiveTable 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.