Server functions

Code in a project's *.server.ts files runs on the app's backend, never on the device: the app imports a server function and awaits it like any async function, and a channel sends the server's messages to the apps that listen. Call your own server code here; keep records in the database; accounts and sign-in are Sign-in; files users upload are Stored files; a real-time game between players is Net; any other web server is fetch / WebSocket.

  • call my own server code from the app and get the result back
  • show data from the server on a screen, with loading and error states (resource)
  • run a server call from a button and dim the button while it runs (action)
  • refuse a call with a status and a message the app can show (ApiError, RpcError)
  • push live updates from the server to every app that listens (channel)
  • send each user only their own updates, or a chat room's messages to that room (groupBy)
  • know who is calling: the address and the headers of a call (request)
  • run and test the backend on my machine (lecodes dev, lecodes test)

Platforms: iOS, Android, desktop, the web and the web player; on desktop a channel reaches only a backend on your machine. Every call needs a build that carries a backend — see Where the backend runs.

At a glance

TypeScript
// dice.server.ts — runs on the app backend. Every exported ASYNC function is an endpoint; a function that is
// not async is a helper of the server, for server files only (the app's import of it is refused).
export async function roll(sides: number, count = 1) {
  if (!Number.isInteger(sides) || sides < 2) throw new ApiError(400, "sides must be a whole number, 2 or more")
  const dice = Array.from({ length: count }, () => 1 + Math.floor(Math.random() * sides))
  return { dice, total: dice.reduce((a, b) => a + b, 0) }
}
TypeScript
// main.ts — the app
import { roll } from "./dice.server"

const result = UIText("Tap to roll").style({ fontSize: 28, color: "white" })

const rollTwo = async () => {
  try {
    const r = await roll(6, 2)               // a call to the app backend, typed by the server function
    result.text = `${r.dice.join(" + ")} = ${r.total}`
  } catch (e) {
    if (!(e instanceof RpcError)) throw e
    result.text = `failed (${e.status}): ${e.message}`
  }
}

UIScreen(
  UIColumn(
    result,
    UIButton(UIText("Roll").style({ fontSize: 22, color: "white" }))
      .style({ bgColor: "#4f7cff", borderRadius: 12, px: 32, py: 14 })
      .named("roll")
      .onClick(rollTwo),
  ).style({ gap: 24, alignItems: "center" }),
).style({ bgColor: "#1e2230", alignItems: "center", justifyContent: "center" }).open()

The call reaches the backend the build carries. In le.codes' preview, the published app, lecodes dev, lecodes desktop run and lecodes render / lecodes test it shows the dice. A build with no backend (lecodes compile, web build, app sync, app build desktop) shows failed (0): … — see Where the backend runs.

Show server data on a screen: resource

To show what the server answers — a list, a profile, today's menu — wrap the call in resource: one reactive value that is loading, ready with the data, or failed. No try around the call, no flags by hand.

TypeScript
// menu.server.ts
export type Dish = { name: string, price: number }

export async function todaysMenu(): Promise<Dish[]> {
  return [{ name: "Soup", price: 4 }, { name: "Pasta", price: 9 }]
}
TypeScript
// main.ts
import { todaysMenu } from "./menu.server"

const menu = resource(() => todaysMenu())        // asked now, and again on menu.reload()

UIScreen(
  UIColumn(() => {
    const m = menu.value                         // the screen follows it: loading → ready or failed
    if (m.state === "loading") return [UIText("Loading…")]
    if (m.state === "failed") return [
      UIText(m.offline ? "No connection" : m.message),
      UIButton(UIText("Try again")).named("retry").onClick(() => menu.reload()),
    ]
    return m.data.map((dish) => UIText(`${dish.name} — ${dish.price} €`))
  }).style({ gap: 12, padding: 24 }),
).open()
TypeScript
resource<T>(load: () => Promise<T>, { initial?: T }): { value: Loaded<T>, peek(), reload() }
type Loaded<T> = { state: "loading" } | { state: "ready", data: T } | { state: "failed", message, offline }
dataOf(loaded, otherwise)       // the data once there, `otherwise` until then
failureOf(loaded)               // { message, offline } or null
  • A resource asks at once, again when a signal load read changes (listMessages(room.value) follows the room) and on reload(). An answer that arrives after a newer request is dropped. While asked again, what is shown stays.
  • initial is for rows the bundle carries: the data is ready from the start, the server's answer replaces it, and a failure then leaves it — what is shown is real.
  • For a binding that needs one state only: UIColumn(() => dataOf(menu.value, []).map(Row)), UIText(() => failureOf(menu.value)?.message ?? "").
  • A failure's message is the server's ApiError text, in the words the server chose. offline means the request never reached a server (no network, or no backend in this build) — say that in your own words, the message is the transport's note.

Run a call from a button: action

For what the person does on the server — place an order, send a message — wrap the call in action: the call resolves to the server's answer or to null, busy is true while it runs, and error says why the last call failed.

TypeScript
// orders.server.ts
export async function placeOrder(dish: string, count: number) {
  if (count < 1) throw new ApiError(400, "Order at least one")
  return { number: 1 + Math.floor(Math.random() * 1000), dish, count }
}
TypeScript
// main.ts
import { placeOrder } from "./orders.server"

const order = action((dish: string, count: number) => placeOrder(dish, count))
const status = UIText("")

const orderButton = (label: string, count: number) =>
  UIButton(UIText(label)).named(`order${count}`)
    .style({ opacity: () => order.busy.value ? 0.5 : 1 })     // dimmed while the call runs
    .onClick(async () => {
      const placed = await order("Soup", count)               // null: it failed, or a call was running
      if (placed) status.text = `Order #${placed.number} placed`
      else if (order.error.value) status.text = order.error.value.offline ? "No connection" : order.error.value.message
    })

UIScreen(
  UIColumn(orderButton("Order a soup", 1), orderButton("Order no soup", 0), status).style({ gap: 12, padding: 24 }),
).open()
TypeScript
action<A, R>(run: (...args: A) => Promise<R>): ((...args: A) => Promise<R | null>) & { busy, error }
type Failure = { message: string, offline: boolean }
  • A call made while one runs is ignored: it resolves to null.
  • error is cleared as a call starts. busy and error are computed signals: read .value in a binding and the screen follows them.
  • After a write, ask again for what the screen shows: if (placed) menu.reload().

Write an endpoint: export async function

A server function the app can call is an export async function of a *.server.ts file.

  • A file whose name ends in .server.ts (or .server.js) is server code. It never reaches the app bundle.
  • Its exports are sorted by how they are written:
    • endpoint: export async function f, export const f = async (…) => …, or export { f } of a local async function;
    • channel: export const c = channel<Message>()… (see Live updates from the server);
    • server-only: anything else — a constant, a class, a database handle (see Server-only exports).
  • export default in a server file is a compile error: default exports are not endpoints — use named exports.
  • A server file may import shared files and other server files. A *.server.ts is never the entry.
  • An endpoint's id is <file>#<export>, for example dice.server.ts#roll. It appears in error messages and in request.id.

In app code, an imported endpoint keeps the server function's parameter types, and every call returns a promise.

Note

an endpoint is async. A plain export function add(a: number, b: number) would be imported with the type number while its call is a promise, so it doesn't compile: an endpoint is called over the network, so in the app its result is a promise — declare it async. (A function that hands a promise or a query back as it is — () => db.auth.user() — passes.)

Arguments and results: what crosses the wire

  • Arguments travel positionally as JSON.
    • The server checks them against the endpoint's TypeScript parameter types before the function runs. A mismatch is a 400 that names the path: sides: expected number, got string, v.n: expected number, got string.
    • Optional and defaulted parameters may be left off the end.
    • Extra arguments and unknown object keys are ignored.
    • any / unknown accept anything.
  • undefined at the end of the arguments is the same as leaving them off: roll(6, undefined) is roll(6). An undefined BEFORE another argument arrives as null, which fails a parameter whose type has no null (count: expected number, got null) — type such a parameter number | null.
  • A parameter of type File is an upload: the app passes a picked file or a photo, and the call carries its bytes — see Stored files.
  • Every other parameter type must be JSON. Functions, Date, Map / Set, recursive types and generic parameters fail the le.codes build and lecodes dev with endpoint args must be JSON-serializable: …. lecodes compile does not run this check.
  • The result is serialized as JSON and not validated:
    • undefined (a void endpoint) resolves to null;
    • a Date arrives as its ISO string;
    • undefined properties disappear.

Errors: ApiError on the server, RpcError in the app

To refuse a call, throw ApiError(status, message) in the endpoint: the app's call rejects with an RpcError of that status and message. The At a glance pair shows both ends; an action or a resource keeps the failure for you.

TypeScript
throw new ApiError(status: number, message = "")     // server code: the app's call rejects with this status + message
err instanceof RpcError                               // app code: every failed call
err.status: number                                    // the HTTP-like status, 0 = the request never reached a server
err.message: string
status when message
yours the endpoint threw ApiError(status, message) yours
400 an argument failed the type check <param path>: expected …, got …
409 / 404 the database refused a write: a taken unique value, a relation to no row, a delete restrict forbids (409); a row that is gone (404) the database's words
500 the endpoint threw anything else Internal error — the real error goes to the server's log
504 the call ran past the backend's 30 s cap Endpoint timed out after 30000 ms
0 this build has no backend, the request failed (no network), or a server-only export was used Endpoint <id>: … / <id> is server-only — …
other the response was not the backend's (a proxy's 502, …) Endpoint <id>: unexpected response (HTTP n)

Who is calling: request

To know who is calling an endpoint — its address, its headers — read the server global request. For the signed-in user, use db.auth (Sign-in).

TypeScript
// whoami.server.ts
export async function whoami() {
  return { endpoint: request.id, ip: request.ip ?? null, agent: request.headers["user-agent"] ?? null }
}
  • request.id is the endpoint id, or the channel id inside a channel's authorize / groupBy.
  • request.ip is the caller's address.
  • request.headers holds an endpoint call's HTTP headers, with lower-case names. Inside a channel's hooks it holds only authorization (the session), if there is one.
  • Outside a call (module load, a timer that fires after the response), id and ip are undefined and headers is {}.

Live updates from the server: channels {#channels}

To push news from the server to the apps — a new post, a chat message, an order that changed — export a channel: the server publishes, every app that subscribed gets the message. An endpoint is a call from the app to the server; a channel is the other direction, typed by the same export.

TypeScript
// posts.server.ts
export type Post = { author: string, text: string }

export const postsChannel = channel<Post>()        // every app that subscribed hears every post

export async function sendPost(author: string, text: string) {
  const post = { author, text }
  postsChannel.publish(post)                        // to the apps subscribed right now
  return post
}
TypeScript
// main.ts
import { postsChannel, sendPost, type Post } from "./posts.server"

const feed = signal<Post[]>([])
let posts: ChannelSubscription | null = null

UIScreen(
  UIColumn(() => feed.value.map((p) => UIText(`${p.author}: ${p.text}`))).style({ gap: 8 }),
  UIButton(UIText("Post")).named("post").onClick(() => sendPost("Ann", "Hello, everyone")),
).style({ gap: 16, padding: 24 })
  .onOpen(() => {
    posts = postsChannel.subscribe((post) => { feed.value = [post, ...feed.value] })
  })
  .onClose(() => posts?.close())                    // the screen went away: stop listening
  .open()
TypeScript
channel<Message>()                         // server: export it from a *.server.ts
  .authorize(() => void)                   // who may listen
  .groupBy((...args) => string | number)   // which group a subscriber is in
ch.publish(message)                        // server: to every subscriber
ch.publish(group, message)                 // server: a channel with groupBy — to that group
ch.subscribe([...args,] handler, { reconnect?, error? }): ChannelSubscription   // app: { close() }
  • Name a channel somethingChannel: in the app it sits next to the data it carries.
  • A channel is server → app only. The app talks back through endpoints (sendPost above); publish in app code is a compile error.
  • A channel carries one type of message. Two kinds of news are two channels (or one message type that is a union).
  • A subscription lives until close(). Keep what subscribe answers as a ChannelSubscription.
  • publish from a channel that isn't exported throws.
Note

on the desktop host a channel reaches only a backend on your machine (lecodes dev, desktop run). A le.codes backend is https, so its socket is wss://, which the desktop host does not open: the subscription keeps retrying and no message arrives. Endpoint calls work there.

Each user's own updates, or one room's: groupBy

To send a message to some subscribers only — one chat room, one user's devices — give the channel a groupBy: it runs on the server for each subscription and answers the group that subscriber is in; publish(group, message) reaches that group.

TypeScript
// chat.server.ts
export type Message = { room: string, text: string }
const ROOMS = ["general", "random"]

export const chatChannel = channel<Message>()
  .groupBy(async (room: string) => {               // what the app passes to subscribe, checked as a string
    if (!ROOMS.includes(room)) throw new ApiError(404, "No such room")
    return room                                     // this subscriber's group
  })

export async function say(room: string, text: string) {
  if (!ROOMS.includes(room)) throw new ApiError(404, "No such room")
  chatChannel.publish(room, { room, text })         // only that room's subscribers get it
}
TypeScript
// main.ts
import { chatChannel, say } from "./chat.server"

const lines = signal<string[]>([])

const room = chatChannel.subscribe("general", (m) => { lines.value = [...lines.value, m.text] }, {
  error: (e) => { lines.value = [...lines.value, `refused (${e.status}): ${e.message}`] },
  reconnect: () => { lines.value = [...lines.value, "(reconnected — messages may have been missed)"] },
})

UIScreen(
  UIColumn(() => lines.value.map((t) => UIText(t))).style({ gap: 8 }),
  UIButton(UIText("Say hello")).named("say").onClick(() => say("general", "hello, room")),
).style({ gap: 16, padding: 24 }).open()

For "each their own", take nothing from the app and answer the caller's id — nobody can ask for someone else's:

TypeScript
// notices.server.ts
const User = model({ name: t.string().default("") })
const db = defineDb({ User }).withAuth({ model: "User" })

export type Notice = { text: string }

export const noticesChannel = channel<Notice>()     // each user hears only their own
  .groupBy(async () => (await db.auth.requireUser().select({ id: true })).id)   // a guest is refused (401)

export async function remindMe(text: string) {      // to every device the caller is signed in on
  const me = await db.auth.requireUser().select({ id: true })
  noticesChannel.publish(me.id, { text })
}
TypeScript
// main.ts
import { noticesChannel } from "./notices.server"

const notices = noticesChannel.subscribe((n) => toast(n.text), {
  error: (e) => { if (e.status === 401) console.log("signed out: no notices until the next sign-in") },
})
  • groupBy(fn) runs on the server for each subscription, with request and db.auth as in an endpoint. What the app passes to subscribe before the handler is what fn receives.
    • Those arguments come from the app: they are checked against the parameter types like an endpoint's (400 on a mismatch), so each parameter needs a type. Check that the caller may be in the group they ask for, and throw if not.
    • A group's value is told to its subscriber. Don't put a secret in it.
  • authorize(fn) runs the same way and only decides who may listen. Throw (ApiError) to refuse. It goes before groupBy.
  • A refusal goes to the error handler: an RpcError with the thrown status. It can come more than once (once per session that is refused). Without an error handler it is logged to the console. Nothing is delivered while the subscription is refused.
  • Each time the session changes (a sign-in, a sign-out) the server is asked again for every subscription, and the hooks run for the new user: noticesChannel above switches to the new account, is refused after a sign-out, and comes back by itself after the next sign-in.

Missed messages and reconnects

A channel delivers what is published while the app listens; nothing is kept for later.

  • publish delivers at most once, to the apps subscribed at that moment. Nothing is stored.
  • One WebSocket per backend carries every channel of the app. It opens with the first subscription and closes a few seconds after the last close(). When it drops, the app reconnects with back-off (1, 2, 5, 10, then every 30 s), subscribes again and calls each reconnect. Messages sent while it was down are lost — read the state again in reconnect (a resource's reload()).
  • A socket that can't connect keeps retrying and does not call error.
  • In a build with no backend, subscribe throws RpcError (status 0) at once.

Server-only exports

An export that is neither an endpoint nor a channel stays on the server. In the app it becomes a placeholder, and any use of it throws RpcError with status 0 (dice.server.ts#PAGE is server-only — it can't be used in client code): reading a property, calling it, or converting it to a string or number. await on it does not throw. Keep constants the app needs in an ordinary file that both sides import.

Server code: its own globals, no app SDK

Code in a *.server.ts file has its own set of globals and runs on Bun, not in the app's runtime.

  • Server files get their own globals:
    • ApiError, channel and request, documented on this page;
    • defineDb, model, t, ref — the database. Sign-in is not a global: it is db.auth of a database made with defineDb({...}).withAuth({ model: "User" }) — see Sign-in.
  • The app SDK is not there. Vec3, date, UIText, toast, Net and the rest are app globals. The editor's types know both sets in every file, so one used in a server file type-checks — and the build refuses it: blog.server.ts:25: toast is a global of the app — it does not exist in server code.
  • It works the other way round too: a server global in app code (new ApiError(…), model({…})) fails the build the same way. The app catches a server's error as RpcError.
  • A name your file declares itself (const t = …, a parameter request) is yours and is never refused. Types are not either: function f(at: Vec3) in a server file builds.
  • Server code runs on Bun, so JavaScript's standard globals are there (Date, JSON, crypto, timers, fetch).
    • That fetch is the standard one: its body reads return promises, so write await res.json().
    • The project's types describe the app SDK's fetch, whose res.json() is synchronous, so the missing await is not caught.
  • Module-level variables are not storage. Each project's server runs in a worker that is replaced on every deploy, stopped after 15 idle minutes and restarted when a call times out. Keep data in the database. A timer is not a scheduler either: it stops with the worker.
  • console.* output and unexpected errors (with their stack) go to the server's log. The app only sees Internal error.

Where the backend runs {#where-it-runs}

An app reaches the backend whose URL its build carries; which backend that is depends on where the app was built.

On le.codes: the preview's backend and the published app's

  • A build on le.codes bakes the backend's URL into the stubs and deploys the server files with it. A project has two backends, each with its own database and stored files:
    • development — the editor's preview, the project link on a phone and the Data tab. Every compile of the project's current code deploys to it;
    • production — the published app: its web page, its shells' over-the-air updates, and the le.codes viewer app when it opens the published project. Only a publish (or a switch to an older release) deploys to it; its database holds what the app's users wrote.
  • A schema change that deletes data (a field or a model removed) waits for the author's confirmation: the editor asks, lecodes publish asks in the terminal (--allow-destructive applies it without asking).
  • A change the database can't take in place (a field that changes its type) is refused:
    • in the editor, until the development database is reset — its rows and files are deleted;
    • on a publish, outright: nothing changes, and the published app keeps its server and its data. The production database has no reset: change the models so the database can take the change instead (a new field under a new name — see Database).
  • A project whose database has sign-in (withAuth) also gets a device session. The transport stores its token per backend and sends it with every call and channel, and app code never handles it. A sign-in in the preview is not a sign-in in the published app.

On your machine: lecodes dev and lecodes web dev

The dev server runs the server files itself and the app it serves calls them — on a phone, in the desktop host and in the browser alike:

  • the database is a folder of the project, .lecodes/db; it stays between runs. Delete the folder to start empty;
  • saving a server file restarts the server with the new code before the app reloads. The data stays. A change of models the database can't take in place starts an empty database and moves the old one to .lecodes/db-old (its stored files to .lecodes/files-old);
  • console.* output and errors of server code print in the terminal as [server:log] …;
  • an email sign-in code is printed in the terminal instead of being mailed, and 000000 signs any email in with no code sent;
  • the first run of a project with models asks to install the database engine (marcidb-embedded), once;
  • server code runs on the runtime the CLI runs on (Node for the npm install), not on Bun: keep to the standard globals.

In local runs and tests: desktop run, render, test

These commands run the server files on your machine for as long as they run — headless and on every host (--desktop, --sim, --lite, --web):

  • desktop run and render use the same database as lecodes dev (.lecodes/db). One process holds it: while lecodes dev runs, desktop run and render --desktop fail saying the database is in use, and a headless render warns and renders without the backend;
  • test uses a database of its own, .lecodes/test-db, emptied before every case — a test never sees or changes the data of lecodes dev;
  • a step of a flow waits for the calls it started (5 s at most): after { "tap": "send" } the answer is in, no wait is needed. A message from a channel comes on its own — give it a short wait.

Builds with no backend: compile, web build, app sync, app build desktop

lecodes compile, lecodes web build, lecodes app sync (the app.js an iOS or Android shell carries) and lecodes app build desktop build the app with no backend URL:

  • the stubs are still generated and the app still builds;
  • every endpoint call rejects with RpcError status 0: Endpoint dice.server.ts#roll: the project has no deployed server (compile with serverUrl);
  • subscribe throws the same error;
  • a shell runs that bundle until its first over-the-air update — the published app's bundle, built with the production backend — is downloaded; an update runs from the next launch.

Compiler output — __rpc, __channel, __serverOnly

TypeScript
// what the compiler puts in the app bundle in place of dice.server.ts
export const roll = __rpc("<backend url>", "dice.server.ts#roll")

__rpc(url, id), __channel(url, id) and __serverOnly(id) build the stubs described above. The compiler emits one per export of a server file. They are compiler output — never call them by hand. Import the server function instead.

Pitfalls

TypeScript
export function add(a: number, b: number) { … }          // ✗ compile error: an endpoint is async
export async function add(a: number, b: number) { … }    // ✓

const n = add(1, 2)                 // ✗ a promise
const n = await add(1, 2)           // ✓

postsChannel.publish(post)          // ✗ in app code — compile error: the server publishes
await sendPost(author, text)        // ✓ an endpoint that publishes

See also

  • Database — models and queries in server code
  • Sign-in — accounts, db.auth
  • Stored files — File parameters and t.file() fields
  • Multiplayer — real-time sessions between app instances
  • Network — fetch and WebSocket to other web servers
  • Storage — localStorage on the device