# 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 Example /** @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 `.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 ```ts 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(` 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: ```ehtml Template
createUser(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: ```ehtml app/pages/home/template.ehtml ``` ```text 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: ```ts 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`); } ``` ```ehtml app/pages/home/template.ehtml ``` 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 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. ```ts 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`: ```ts 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` map (per-field map that survives the RPC boundary intact, so the browser can `errors = err.errors` and re-render inline). ```ts 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 = {}; 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. ```ts Example /** @rpc */ export function signinAs(token: string) { let user = sql(` 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: ```ts Example import { session, sql, ForbiddenError } from "@elements/app"; /** @rpc */ export function getRoom(id: string): Room { let room = sql(` 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: ```ts 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. ```ts // app/pages/dashboard/index.ts (route handler) import { Request, Response } from "@elements/app"; import dashboard from "./template"; export default function route(req: Request, res: Response) { 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: ```ehtml app/pages/dashboard/template.ehtml 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("/"); } ``` 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. ```ts 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: ```ehtml Template
  • {msg.text}
``` 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.