brumate

Server functions

A function with 'use server' runs only on the server. Client code calls it like any other async function: the arguments go to the server, and the result comes back.

App.brmEdit the code to run it again

The sample runs when it comes into view.

The function body, and anything only it uses, such as a database client, stays on the server. The browser gets a small function that sends the call.

Declaring them

Put 'use server' at the top of an async function, in any script of a component:

async function save(list: Todo[]) {
  'use server';
  await db.todos.save(list);
  return list.length;
}

An import that only server functions use stays on the server, so a database client can be imported at the top of the component:

import { db } from '../db.ts';

async function listTodos() {
  'use server';
  return db.todos.all();
}

A file of server functions

Put 'use server' at the top of a .ts file, and every function that it exports is a server function. Components import them like any function:

// src/todos.ts
'use server';

import { db } from './db.ts';

export async function listTodos() {
  return db.todos.all();
}

export async function saveTodo(todo: Todo) {
  await db.todos.save(todo);
}
<script>
  import { listTodos, saveTodo } from '../todos.ts';
</script>

There is no 'use client'. The compiler decides what reaches the browser by following event handlers and other client code. See What runs where.

Calling them

From a handler, await the call. The handler runs in the browser, so the call goes over the network.

From the setup code of a component, the call is a plain function call on the server, because setup code runs only there.

From a $memo, the value loads on the server for the first render, and again in the browser when the values that the memo reads change:

let id = $signal(1);
const user = $memo(getUser(id));

Async data covers memos that load data, $isPending and #loading.

Rules

Capturing an instance variable is a compile error that names the variable:

Server function "save" captures "user" (Todos.brm:6:3). Pass it as an argument.

Validating arguments

The types of a server function do not check what the browser sends. parse from brumate/server checks a value with a schema, and returns the value that the schema gives back:

import { parse } from 'brumate/server';
import * as v from 'valibot';

const Todo = v.object({ title: v.pipe(v.string(), v.minLength(1)), done: v.boolean() });

async function save(input: unknown) {
  'use server';
  const todo = await parse(Todo, input);
  await db.todos.save(todo);
}

safeParse(schema, value) does not throw. It returns { value } or { issues }, so the function can return the issues for the page to show:

App.brmEdit the code to run it again

The sample runs when it comes into view.

Errors

Throw a ServerError to answer with a message and a status. The call in the browser rejects with an error that has the same message.

App.brmEdit the code to run it again

The sample runs when it comes into view.

The request

getEvent() from brumate/server returns the event of the call: its request, and methods that change the response. Call it before the first await of the function, and keep it in a variable:

import { ServerError, getEvent, redirect } from 'brumate/server';

async function login(name: string) {
  'use server';
  const event = getEvent();
  const agent = event.request.headers.get('user-agent');
  const user = await db.users.find(name);
  if (!user) {
    throw new ServerError('Unknown user', { status: 404 });
  }
  event.setCookie('user', user.id);
  return `Hello ${user.name}`;
}

Why before the first await: a server handles many requests at once, and other requests run while one waits. The runtime sets the event while it starts the function, and clears it at the function's first await. After that, getEvent() throws, so it never returns the event of another request. The event in your variable stays right after any number of awaits.

Helpers take the event as an argument:

async function checkout(cart: Cart) {
  'use server';
  const event = getEvent();
  const user = await currentUser(event);
  await charge(user, cart);
  event.refresh(listOrders);
}

This needs no AsyncLocalStorage or other runtime support, so it runs on any host.

In a component, getEvent() returns the event of the page anywhere in its script while the page renders. There, event.setStatus(404) also sets the status of the page. See Routing. Middleware and endpoints read it before their first await, as server functions do.

When the browser no longer needs an answer, such as a memo whose input changed again, it aborts the call. event.signal tells the function, so it can stop work it no longer needs. It aborts when event.request.signal does, and when the reader of the answer goes away:

async function search(query: string) {
  'use server';
  return db.products.search(query, { signal: getEvent().signal });
}

When the browser leaves while a page streams, getEvent().signal aborts too: for a page, a server function call and the request for a #defer body. The bodies that are not sent yet are not sent.

Cookies and sessions

The event reads the cookies of the request and sets them on the response:

import { getEvent } from 'brumate/server';

async function chooseTheme(theme: 'light' | 'dark') {
  'use server';
  getEvent().setCookie('theme', theme, { maxAge: 60 * 60 * 24 * 365 });
}

async function currentTheme() {
  'use server';
  return getEvent().getCookie('theme') ?? 'light';
}

createSession keeps data in a signed cookie, such as who is logged in:

// src/session.ts
import { createSession } from 'brumate/server';
import env from './env.ts';

export const session = createSession<{ userId: string }>({
  secret: env.SESSION_SECRET,
});
// src/auth.ts
'use server';

import { getEvent, redirect } from 'brumate/server';
import { session } from './session.ts';

export async function login(form: FormData) {
  const event = getEvent();
  const user = await checkPassword(String(form.get('email')), String(form.get('password')));
  await session.set(event, { userId: user.id });
  redirect('/');
}

export async function logout() {
  session.clear(getEvent());
  redirect('/login');
}

export async function currentUser() {
  const data = await session.get(getEvent());
  return data && findUser(data.userId);
}

A custom Node server sends the cookies with toNodeHeaders(answer.headers), which keeps each one apart. See Deploying.

Files

Pass a File or a Blob as an argument, or inside an array or a plain object. The server function gets a File.

App.brmEdit the code to run it again

The sample runs when it comes into view.

Streaming results

A server function can return a promise or an async iterable, alone or inside other values. Each one settles in the browser as soon as the server has it, while the rest of the answer is still on its way. A $memo of an async iterable shows each value in turn. See Async data.

One call for the same arguments

While a page renders, calls of the same server function with the same arguments share one result. Two components that load getUser(1) make one call:

// Header.brm and Sidebar.brm, on one page
const user = $memo(getUser(id));

Results that requests share

cached from brumate/server keeps the results of a function for a time, by its arguments, so the next request gets them without running it. Use it for data that every user sees the same:

// issues.ts
'use server';

import { cached, revalidate } from 'brumate/server';

export const listIssues = cached(async (status: string) => db.issues(status), {
  maxAge: 30,
  tags: (status) => ['issues', `issues:${status}`],
});

export async function closeIssue(id: string) {
  await db.close(id);
  await revalidate('issues');
}

Results live in the memory of the server by default, up to 1000 of them. With several processes, such as on a serverless host, give each function a name and a store that they share:

import { cached, setCacheStore, storageStore } from 'brumate/server';

setCacheStore(storageStore(useStorage('cache')));

export const listIssues = cached(load, { name: 'issues', maxAge: 30 });

storageStore takes an unstorage storage, such as Nitro's useStorage(), whose drivers cover Redis, Cloudflare KV and others. A store is any object with get(key) and set(key, value, maxAge).

One call for many keys

A list whose rows each load one thing, such as the profile of each player, makes one call per row. batched from brumate/batch turns the calls of one moment in the browser into one call with every key:

// profiles.ts
'use server';

export async function getProfiles(ids: string[]): Promise<Map<string, Profile>> {
  return new Map((await db.profiles(ids)).map((profile) => [profile.id, profile]));
}
// profile.ts, a plain module, since a 'use server' file exports only server functions
import { batched } from 'brumate/batch';
import { getProfiles } from './profiles.ts';

export const getProfile = batched(getProfiles, (found, id: string) => found.get(id) ?? null, {
  limit: 50,
});
<!-- Row.brm: each row asks for one profile. -->
<script>
  const profile = $memo(getProfile(id));
</script>