brust 2.0.0-alpha.5
Guide / Actions

Actions

Actions are the server endpoints your pages call. In brust they are one Elysia 2 app, served behind the Rust server: no second port, no second router.

1Turn actions on

Install the two pinned packages and add an [actions] table to brust.toml. Without the table, actions are off even if actions.ts exists.

~/my-app
$ bun add [email protected] @elysia/[email protected]
brust.toml
[actions]
# every key is optional:
# entry = "actions.ts"
# prefix = "/_brust/action"
# max_body_bytes = 1048576
# max_response_bytes = 8388608
# cross_site = "reject"
# base_url = "https://api.example.com"   # actions on another origin (step 6)
# credentials = "same-origin"

2Write the app

The entry default-exports an Elysia app built with the brust() adapter, and exports its type. Route methods take (path, hook, handler): the schema comes before the handler. Do not call app.listen(): Rust serves the app.

actions.ts
// actions.ts: a team kept in a cookie (the app the demo below mirrors in the browser)
import { brust } from '@brust/core/elysia'
import { Elysia, problem, t } from 'elysia'

const app = new Elysia({ adapter: brust() })
  .get('/team', ({ request }) => ({ team: teamFromCookie(request.headers.get('cookie')), max: 6 }))
  .post('/team', { body: t.Object({ id: t.Integer({ minimum: 1 }) }) }, ({ body, request, set }) => {
    const team = teamFromCookie(request.headers.get('cookie'))
    if (team.length >= 6) return problem(409, { title: 'TeamFull', detail: 'the team already has 6 members', max: 6 })
    return save(set, [...team, member(body.id)])
  })
  .delete('/team/:id', { params: t.Object({ id: t.Integer({ minimum: 1 }) }) }, ({ params, request, set }) =>
    save(set, teamFromCookie(request.headers.get('cookie')).filter((m) => m.id !== params.id)))

export default app
export type App = typeof app

Every request under the prefix, /_brust/action by default, goes to this app in a Bun worker. Rust answers CORS preflights, caps the request body (413) and the response size, before and after your code.

3Call it from a component

client<App>() from @brust/core/client is a typed Eden Treaty client, already pointed at the prefix. Import the app's type only: a value import would pull Elysia into the browser, and the build refuses it.

components/team-client.ts
// components/team-client.ts
import { client } from '@brust/core/client'
import type { App } from '../actions'   // a type import: no Elysia in the browser
import { team } from './team-store'

const api = client<App>()

export async function addMember(id: number): Promise<string> {
  const r = await api.team.post({ id })
  if (r.error) return r.error.status === 409 ? 'team full' : `failed with status ${r.error.status}`
  team.members.set(r.data.team)
  return ''
}

Every call resolves to { data, error, status, response, headers }, with exactly one of data and error set. A network failure resolves with status 503.

4Try it

On this site the demo runs in your browser; with [actions] on a brust server the same buttons call POST /_brust/action/team. The rules are the app's above: at most 6, a Pokémon already in the team is not added twice, and the seventh gets the TeamFull 409. The team is kept in your browser's storage and goes into the team store that the buttons read. The Store page shows the same team.

runs in your browserTeamPicker · native

Team 0 / 6. Add a seventh to see the 409.

5Errors and limits

actions.ts
return problem(409, { title: 'TeamFull', max: 6 })   // returned: typed on the client
throw new TeamFull(6)                                  // an HTTPError.id('TEAM_FULL') subclass: unknown on the client
throw new Error('boom')                                // 500 problem+json, detail only outside production
  • An unknown path or a wrong method under the prefix is a 404 problem+json from Elysia; there is no 405.
  • Outside the prefix, a non-GET request to a page is a 405 from Rust.
  • Cross-site requests are rejected by default (cross_site = "reject"). Behind a proxy, pass Host through or set X-Forwarded-Host, or same-origin POSTs get a 403.
Note

A store written inside an action is per-request and never reaches the page. Return the data and set the store in the browser with the reply, as addMember does.

6Actions on their own origin

The pages and the actions can be two deployments: static pages on Cloudflare Pages and the actions on api.example.com, or an API server next to a front end hosted elsewhere. One build flag on the actions side, one setting on the pages side.

The actions deployment

brust build --actions-only builds a dist/ with no pages: no routes, no templates, no jobs, no client chunks. The app still runs behind the same Rust admission: method gate, body caps, Content-Encoding, the cross-site guard and CORS. Every path outside the prefix is a 404 application/problem+json.

api/brust.toml
[actions]
entry = "actions.ts"
prefix = "/"                            # only in an --actions-only build

[cors]
origins = ["https://app.example.com"]   # the exact page origin
credentials = true                      # only if the page sends cookies
~/api
$ brust build --actions-only
$ brust start
  • It combines with --target bun (the default: brust start serves it) and --target cloudflare (a Worker with only actions and no Static Assets). It cannot be combined with --target static, and it needs an [actions] table. [cache] and route options are ignored, with an info line.
  • prefix = "/" is allowed only here (and in a page-only build that points at such a deployment), so https://api.example.com/team is the app's /team. A normal build refuses it: its page routes would be unreachable.
  • Any other prefix starts with /, has no trailing /, uses only [A-Za-z0-9._~-] segments (never . or ..), and stays out of /_brust/; the default /_brust/action is the one exception.
  • Your routes are still written without the prefix and request.url is the real URL. The server's own paths (/ping, /_brust/..., /public/...) stay the server's.

The pages deployment

On the pages side, [actions] base_url tells the browser client where the actions live, and credentials sets the default RequestInit.credentials of every call.

web/brust.toml
[actions]
base_url = "https://api.example.com"   # an origin: no path, query, fragment or credentials
prefix = "/"                           # the prefix the other deployment mounts at
credentials = "include"                # same-origin (default) | include | omit
  • Both are runtime settings, like [cors]: each environment points at its own API without a rebuild. base_url comes from the env BRUST_ACTIONS_BASE_URL, then run({ config: { actionsBaseUrl } }), then the toml. credentials comes from run({ config: { actionsCredentials } }), then the toml.
  • The server injects them into every page, next to the prefix, as JSON string literals that are safe inside a script:
every page
<script>globalThis.__BRUST_ACTION_PREFIX__="/"</script><script>globalThis.__BRUST_ACTION_BASE__="https://api.example.com";globalThis.__BRUST_ACTION_CREDENTIALS__="include"</script>
  • The client resolves the base as client({ baseUrl }), then the page's injected base, then the page's own origin. The typed client keeps importing type App from the shared source, so calls stay typed across the two deployments.
  • init passed to client() overrides the credentials default for that client.
  • A page-only build. With a base_url and no actions.ts, the build bundles no actions and mounts no local app; the base and prefix are still injected. A missing file that entry names explicitly is an error, never a page-only build.
  • With --target static, the base is baked into the rendered pages from the build's environment. That is how static pages on Pages call actions on their own domain. See Deployment.

7Calling across origins

Three things decide whether a cross-origin call works: the actions server's [cors], its cross-site guard, and the browser's rule for credentials. Change the settings below to see each step.

runs in your browser, sends nothingCrossOriginCheck · react
browser cross-origin: the browser first sends OPTIONS https://api.example.com/… (Eden's JSON content-type makes it a preflighted request)
server  204 preflight from Rust, Access-Control-Allow-Origin: https://app.example.com, Access-Control-Allow-Credentials: true
server  POST: the cross-site guard lets it through, the Origin is listed by name
server  your Elysia app answers; Rust stamps the CORS headers on the response
browser cookies travel both ways, if the action set them with SameSite=None; Secure

=> reaches your app
  • Every cross-origin call is preflighted, GET included: the client sends JSON. Rust answers the preflight of a listed origin with a 204 and the CORS headers. Any other origin gets no CORS headers, and the browser never sends the call.
  • A cross-site POST from an origin not listed by name in [cors] origins is a 403 CrossSiteRejected, before the body is read. A wildcard "*" does not count; cross_site = "allow" turns the guard off.
  • credentials = "include" needs the exact page origin in [cors] origins and credentials = true. credentials = true with "*" is a boot error.
  • A cookie that must travel cross-site needs SameSite=None; Secure. Browsers treat http://localhost and http://127.0.0.1 as secure, so this works in development.
api/actions.ts
.post('/login', ({ cookie: { session } }) => {
  session.set({ value: token, httpOnly: true, sameSite: 'none', secure: true, path: '/' })
  return { ok: true }
})