DocsDevelopers

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 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#

kindfires onuse it for
onRecordCreateRequest, onRecordUpdateRequest, onRecordDeleteRequest, onRecordViewRequest, onRecordsListRequestan HTTP request onlychecks and changes tied to a caller: reshape e.data, refuse with ApiError, journal who did it
onRecordAfterCreateSuccess, UpdateSuccess, DeleteSuccessevery committed write: a request, a hook, a scriptderived 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.

The request event#

fieldwhat
e.auththe signed in record, or null
e.isSuperusertrue for a _superusers record
e.paramsthe path parameters of a route (:id)
e.body()the JSON or multipart body, parsed once
e.realIp, e.remoteIpthe client address behind trustedProxy, and the socket's address
e.json(status, data), e.noContent()answer
e.record, e.original, e.data, e.collectionon record hooks

Custom routes#

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:

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.
  • app.serve({ port }) returns Bun's server. app.close() stops the server and the crons and closes the database.

This page as markdown