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#
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.
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
tryaroundnext()catches what the action throws. - A handler that does not call
next()stops the chain: the default action never runs. It answers withe.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:
- 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. - A write that a hook makes through
app.recordsfires 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.
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#
app.route('GET', '/api/aggregate/orders', handler, requireAuth('users'))
- Paths take
:nameparameters 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 aResponse. - Rules do not apply inside a route.
app.recordsis the trusted service andapp.dbis raw SQL: check the caller yourself, and read a tenant or an owner frome.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 / deleteis 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 thebun:sqlitedatabase. 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:
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 isnew Bun.CryptoHasher('sha256'). - The audit journal binds its own request hooks:
journal(app, { collections: ['clients', 'orders'], logins: ['users'] }), andjournalRow(app, e, { action, collection, record, label })for an action of your own. See UI: shell, grids, forms. app.serve({ port })returns Bun's server.app.close()stops the server and the crons and closes the database.