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 of yours and no imports to set up: the functions below are globals, and the code is written exactly as for PocketBase.

/// <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.

voidbase serve --dev

--dev restarts the server when a hook or a migration changes, so editing a file is the whole loop. --hooksDir points at another directory, as VOIDBASE_HOOKS_DIR does.

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(), $apis.requireSuperuserAuth(), $apis.requireGuestOnly() and $apis.requireSuperuserOrOwnerAuth() are the guards, and routerUse registers middleware on every request.

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. onBootstrap and onServe run once per isolate on its first request. Three are registered and never fired: onTerminate, onBackupCreate and onBackupRestore.

Scheduled work

cronAdd(name, expression, handler), with a standard five-field expression. On Cloudflare every expression the bundle contains becomes one of the Worker's own scheduled triggers (more than four falls back to every minute), so they run whether or not anyone is visiting; a job may run on any isolate, so keep it idempotent. POST /api/crons/:id runs one on demand.

What is available inside

GlobalWhat you get
$appThe database: findRecordById, findFirstRecordByFilter, findRecordsByFilter, countRecords, expandRecord, save, delete, importCollections, truncateCollection, plus settings(), logger(), newMailClient() and dao() for raw SQL.
$apisRoute guards: requireAuth, requireSuperuserAuth, requireGuestOnly, requireSuperuserOrOwnerAuth.
$httpsend({url, method, body, headers, timeout}), for calling other services.
$filesystemfileFromURL and fileFromBytes. fileFromPath throws: there is no filesystem on Workers.
$securityrandomString, randomStringWithAlphabet, pseudorandomString, sha256.
$osgetenv reads configuration, which is what pb_secrets declares; readFile reads files bundled from the hooks directory. cmd and exec throw.
$dbxexp and hashExp, for building query expressions.

The classes are there too: Record, Collection, MailerMessage, DateTime, the typed field classes, and the error classes BadRequestError, ForbiddenError, NotFoundError, UnauthorizedError and ValidationError. $template is a placeholder: build mail bodies as strings and send them with $app.newMailClient().send(new MailerMessage({...})).

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`);

Two differences 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. And $app.*, $http.send, $filesystem.* and mail calls are asynchronous underneath: the bundler inserts the awaits, so ordinary synchronous-looking PocketBase code works unchanged, and $app.runInTransaction(fn) runs fn directly rather than opening a transaction.

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. Exactly what voidbase implements, event by event and global by global, is docs/hooks.md.

Found something wrong on this page?

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.