pb_hooks
Server-side JavaScript that runs inside the instance: endpoints the records API does not have, handlers that fire around writes, and work on a schedule. This is where a project stops being a database with an API and starts being your application.
The shape of it
Any file ending .pb.js in the directory is loaded, in filename order. There is no build step and no imports to set up: the functions below are globals.
/// <reference path="../pb_data/types.d.ts" />
// a new endpoint, for anything the records API does not already do
routerAdd("GET", "/api/me/summary", (e) => {
const posts = $app.countRecords("posts", $dbx.hashExp({ author: e.auth.id }));
return e.json(200, { email: e.auth.email(), posts });
}, $apis.requireAuth());
// something that happens whenever a record is written
onRecordCreateRequest((e) => {
e.record.set("slug", e.record.get("title").toLowerCase().replaceAll(" ", "-"));
e.next(); // run the rest of the handlers, then the write itself
}, "posts");
// and something that happens on a schedule
cronAdd("digest", "0 8 * * *", () => {
const users = $app.findRecordsByFilter("users", "verified = true", "-created", 100, 0);
$app.logger().info("digest", "count", users.length);
});The reference comment on the first line is what makes an editor autocomplete all of it. The file it points at is generated into pb_data on the first run, so run the server once before you start writing.
The three things you will write
Endpoints
routerAdd(method, path, handler, ...middleware). The handler gets a request event: e.auth is the signed-in record or null, e.pathParam("id") reads a path segment, e.bindBody(obj) parses the body, and e.json(status, data) answers. $apis.requireAuth() and $apis.requireSuperuserAuth() are the guards.
Event handlers
Every on* function registers a handler, and trailing arguments limit it to named collections. Calling e.next() runs the remaining handlers and then the action itself, so anything before that call happens first and anything after it happens once the write has gone through. Throwing instead of calling it refuses the request:
onRecordUpdateRequest((e) => {
if (e.record.get("locked") && !e.auth?.isSuperuser()) {
throw new ForbiddenError("this post is locked");
}
e.next();
}, "posts");The families are the ones PocketBase has: records both as models and as requests, collections, auth, files, realtime, settings, mail and batches, each with Validate, the action itself, and After...Success / After...Error variants.
Scheduled work
cronAdd(name, expression, handler), with a standard five-field expression. On Cloudflare these become the Worker's own scheduled triggers, so they run whether or not anyone is visiting.
What is available inside
| Global | What you get |
|---|---|
$app | The database: findRecordById, findFirstRecordByFilter, findRecordsByFilter, countRecords, expandRecord, save, delete, plus settings(), logger() and newMailClient(). |
$apis | Route guards: requireAuth, requireSuperuserAuth, requireGuestOnly. |
$http | send({url, method, body, headers}), for calling other services. |
$security | randomString, sha256, and friends. |
$os.getenv | Configuration, which is what pb_secrets declares. |
$dbx | exp and hashExp, for building query expressions. |
Sharing code between files
require resolves against the hooks directory, and __hooks is its path, so helpers live wherever you like inside it:
// pb_hooks/lib/slug.js
module.exports = { slugify: (s) => s.toLowerCase().trim().replaceAll(/[^a-z0-9]+/g, "-") };
// pb_hooks/posts.pb.js
const { slugify } = require(`${__hooks}/lib/slug.js`);One difference from PocketBase worth knowing: on Cloudflare the hooks are compiled into the Worker when you deploy, so a change to a hook needs a deploy rather than a restart. Locally, voidbase serve --dev restarts on save.
Where to read on
The hook API is PocketBase's, and every function above is documented under Extend with JavaScript, with the full event list under Event hooks.
Fix it yourself. The link below opens this file in GitHub's editor and forks the repository for you if you need one, and your change becomes a pull request without leaving the browser.