brust 2.0.0-alpha.5
Cookbook / Form action

Form action

A form that adds a Pokémon to a team of up to six. The server side is one Elysia 2 app behind the Rust server; the form is a native component.

1Turn actions on

Actions are opt-in. Install the two pinned packages and add an [actions] table; every key in it is optional.

~/my-app
$ bun add [email protected] @elysia/[email protected]
brust.toml
[actions]

2Write the action

The entry default-exports an Elysia app built with the brust() adapter. In Elysia 2 the argument order is (path, hook, handler): the schema comes before the handler. A body that fails the schema answers 422; a returned problem(409, ...) is typed on the client.

actions.ts
import { brust } from '@brust/core/elysia'
import { Elysia, problem, t } from 'elysia'

const team: number[] = []
const MAX = 6

const app = new Elysia({ adapter: brust() })
  .get('/team', () => ({ team, max: MAX }))
  .post('/team', { body: t.Object({ id: t.Integer({ minimum: 1 }) }) }, ({ body }) => {
    if (team.length >= MAX) return problem(409, { title: 'TeamFull', max: MAX })
    if (!team.includes(body.id)) team.push(body.id)
    return { team, max: MAX }
  })

export default app
export type App = typeof app

3Call it from a form

client<App>() is Eden Treaty wired to the actions mount. The import type is erased, so the browser bundle carries no Elysia. This form has state and handlers but nothing the native tier cannot express, so it compiles to a native component.

lib/api.ts
import { client } from '@brust/core/client'
import type { App } from '../actions'

export const api = client<App>()
components/AddToTeam.tsx
import { useState } from 'react'
import { api } from '../lib/api'

export default function AddToTeam() {
  const [id, setId] = useState('25')
  const [msg, setMsg] = useState('')
  const submit = async (e: { preventDefault(): void }) => {
    e.preventDefault()
    const r = await api.team.post({ id: Number(id) })
    if (r.error) setMsg(r.error.status === 409 ? 'The team is full' : `Failed with ${r.error.status}`)
    else setMsg(`Team: ${r.data.team.length}/${r.data.max}`)
  }
  return (
    <form onSubmit={submit}>
      <input name="id" value={id} onChange={(e) => setId(e.target.value)} />
      <button type="submit">Add to team</button>
      <p>{msg}</p>
    </form>
  )
}

4Try it

The same endpoint with curl: a valid body, then one that fails validation.

~/my-app
$ curl -si -X POST http://127.0.0.1:1337/_brust/action/team -H 'content-type: application/json' -d '{"id":25}'
HTTP/1.1 200 OK
{"team":[25],"max":6}

$ curl -si -X POST http://127.0.0.1:1337/_brust/action/team -H 'content-type: application/json' -d '{"id":0}'
HTTP/1.1 422 Unprocessable Entity
content-type: application/problem+json
{"type":"validation","code":"validation","title":"Validation Error","status":422,"on":"body","property":"/id"}
Note

Actions are public endpoints. brust rejects cross-site non-GET requests by default (403), and nothing more: authentication is your app’s job. The team array above lives in one worker’s memory, so keep real state in a database. See Actions.