# Server: hooks, routes, crons

> createApp builds the app's server; hooks wrap every write and sign in, custom routes add endpoints, crons run on a schedule, all in plain TypeScript.

`server.ts` creates the app, binds its behavior, and serves it. Hooks, routes and crons are plain TypeScript functions in the app's own process. There is no VM and no separate runtime.

## A full server.ts

```ts
import { createApp, requireAuth, ApiError, smtpTransport } from 'locosdk/server'
import config from './loco.config.ts'
import { schema } from './schema.ts'
import index from './src/index.html'

const app = createApp({
    schema,
    app: config.app,
    pages: { '/*': index },
    meta: { appName: 'Orders', appURL: 'https://orders.example.com', senderName: 'Orders', senderAddress: 'no-reply@example.com' },
    mail: smtpTransport({ host: 'smtp.example.com', port: 587, username: 'u', password: process.env.SMTP_PASSWORD ?? '' }),
    trustedProxy: { headers: ['X-Forwarded-For'] },
})

// a request hook wraps next(): only a write that persisted is logged
app.onRecordUpdateRequest(async (e) => {
    const before = e.original
    await e.next()
    await app.records.create('audit_log', { actor: e.auth?.id ?? '', record: e.record?.id ?? '', before, after: e.record })
}, 'orders')

// an after success hook: runs for every write, from a request, a hook or a script
app.onRecordAfterCreateSuccess(async (e) => {
    const order = String(e.record.order)
    const row = app.db.query<{ total: number }, [string]>('SELECT coalesce(sum(amount), 0) AS total FROM payments WHERE "order" = ?').get(order)
    await app.records.update('orders', order, { paid: row?.total ?? 0 })
    await e.next()
}, 'payments')

// a custom route: the route itself is the access control
app.route('POST', '/api/send-invoice', async (e) => {
    const { to, pdf } = await e.body()
    if (typeof to !== 'string') throw new ApiError(400, 'Missing to.')
    await app.mail.send({
        from: { address: app.meta.senderAddress, name: app.meta.senderName },
        to: [{ address: to }],
        subject: 'Your invoice',
        html: '<p>Your invoice is attached.</p>',
        attachments: [{ filename: 'invoice.pdf', content: Buffer.from(String(pdf), 'base64') }],
    })
    return e.json(200, { ok: true })
}, requireAuth('users'))

// every mail gets a footer
app.onMailerSend(async (e) => {
    e.message.html += '<p>Orders, 1 Example Street</p>'
    await e.next()
})

app.cron('nightly', '0 23 * * *', async () => {
    const res = await fetch('https://api.example.com/rates')
    const digest = new Bun.CryptoHasher('sha256').update(await res.text()).digest('hex')
    app.db.query('UPDATE settings SET rates_hash = ? WHERE id = ?').run(digest, 'main')
})

app.serve()
```

The options of `createApp` and the full app object: [createApp](https://golocomotive.fr/docs/reference/create-app.md).

## The hook chain

A hook is a handler `(e) => …`, bound to some collections (none = every collection). Handlers run in the order they were bound. Each one MUST `await e.next()` for the default action to run.

- Code before `next()` runs before the default action. Code after it runs once the action succeeded.
- A `try` around `next()` catches what the action throws.
- A handler that does not call `next()` stops the chain: the default action never runs. It answers with `e.json(...)` instead.

Routes and their middlewares are the same chain.

## Request hooks and after success hooks

| kind | fires on | use it for |
|---|---|---|
| `onRecordCreateRequest`, `onRecordUpdateRequest`, `onRecordDeleteRequest`, `onRecordViewRequest`, `onRecordsListRequest` | an HTTP request only | checks and changes tied to a caller: reshape `e.data`, refuse with `ApiError`, journal who did it |
| `onRecordAfterCreateSuccess`, `UpdateSuccess`, `DeleteSuccess` | every committed write: a request, a hook, a script | derived values that must stay true whoever writes |

Two facts bite first:

1. A request hook runs BEFORE the rule check of its write. Put side effects after `await e.next()`, or a refused write still has them.
2. A write that a hook makes through `app.records` fires the after success hooks, never the request hooks.

After success hooks run after the commit, outside the write lock, in order. A throw there is logged and the write stays.

The auth flows have their own request hooks (`onRecordAuthRequest`, `onRecordAuthWithPasswordRequest`, `onRecordRequestOTPRequest`…). The full list: [Hooks](https://golocomotive.fr/docs/reference/hooks.md).

## The request event

| field | what |
|---|---|
| `e.auth` | the signed in record, or null |
| `e.isSuperuser` | true for a `_superusers` record |
| `e.params` | the path parameters of a route (`:id`) |
| `e.body()` | the JSON or multipart body, parsed once |
| `e.realIp`, `e.remoteIp` | the client address behind `trustedProxy`, and the socket's address |
| `e.json(status, data)`, `e.noContent()` | answer |
| `e.record`, `e.original`, `e.data`, `e.collection` | on record hooks |

## Custom routes

```ts
app.route('GET', '/api/aggregate/orders', handler, requireAuth('users'))
```

- Paths take `:name` parameters and a `*` wildcard. `/api/*` never falls through to a page.
- `requireAuth(...collections)` answers 401 without a signed in record of those collections. `requireSuperuser()` answers 403.
- A middleware is a handler that calls `e.next()`.
- A handler returns `e.json(...)`, `e.noContent()`, or a `Response`.
- Rules do not apply inside a route. `app.records` is the trusted service and `app.db` is raw SQL: check the caller yourself, and read a tenant or an owner from `e.auth`, never from the query.

Throw `ApiError(status, message, data?)` anywhere for a PocketBase shaped error. Any other throw answers 400 `Something went wrong while processing your request.` and is logged.

## Data from a hook or a route

- `app.records.list / getOne / getFirst / create / update / delete` is the records service with no rules. It validates fields, hashes passwords, stores files and runs cascades like a request does.
- `app.records.transaction(async (tx) => …)` runs several writes in one SQLite transaction. A handle used inside it joins it.
- `app.db.query(sql).all(...params)` is the `bun:sqlite` database. Bind every value. A raw write skips validation and the after success hooks.

## Crons

`app.cron(name, spec, fn)` takes five fields (`* , - /`) or `@hourly @daily @weekly @monthly @yearly`, in UTC, checked each minute. The same name replaces a cron. A throw is logged and the schedule goes on. A built in hourly cron cleans expired codes and idle rate limit entries.

## Mail

`app.mail.send({ from, to, cc, bcc, subject, html, text, attachments })` sends through the `mail` transport and the `onMailerSend` chain. A missing text part is derived from the HTML. `smtpTransport(config)` is the real transport, `memoryTransport()` records messages in tests. With no `mail` option, sending throws.

## Body limits

Every route reads at most 32 MB. A record create or update adds each file field's `maxSize` (5 MB when unset) times its `maxSelect`. `/api/batch` reads 128 MB. A body over the limit gets 413 before the handler runs. A route sets its own limit with a middleware:

```ts
import { bodyLimit } from 'locosdk/server'

app.route('POST', '/api/import', importHandler, requireAuth('users'), bodyLimit(1 << 30))
```

`bodyLimit(0)` means no limit.

## Other things in reach

- Outbound HTTP is `fetch`. A hash is `new Bun.CryptoHasher('sha256')`.
- The audit journal binds its own request hooks: `journal(app, { collections: ['clients', 'orders'], logins: ['users'] })`, and `journalRow(app, e, { action, collection, record, label })` for an action of your own. See [UI: shell, grids, forms](https://golocomotive.fr/docs/developers/ui.md#the-audit-journal).
- `app.serve({ port })` returns Bun's server. `app.close()` stops the server and the crons and closes the database.
