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.

/** @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 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

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:

<form onsubmit={() => createUser(form)}>
  <input value={form.name}>
  <input value={form.email}>
</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:

// app/pages/home/template.html
<button onclick={() => sql(`delete from users`)}>delete all</button>
//                       ^^^ compile error: sql() is server-only

Cross the boundary by moving the call into an rpc function:

/** @rpc */
function deleteAllUsers() {
  session.isLoggedInOrThrow();
  if (sql(`select 1 from admins where id = ${session.getOrThrow("userId")}`).empty()) {
    throw new ForbiddenError();
  }
  sql(`delete from users`);
}
<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.

/** @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.

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:

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).

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-reachable code is a compile error.

/** @rpc */
export function loginAs(token: string) {
  let user = sql<User>(`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:

import { session, sql, NotFoundError, ForbiddenError } from "@elements/app";

/** @rpc */
export function getRoom(id: string): Room {
  let room = sql<Room>(`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:

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.ts (route handler)
import dashboard from "./template";

export default function route(req, res) {
  return new dashboard({ stats: undefined });
}
// app/pages/dashboard/services.ts (rpc)
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:

<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("/");
}

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.

/** @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:

<ul>
  <li e:for={msg of messages}>{msg.text}</li>
</ul>

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.