PocketBase JSVM
PocketBase embeds a Goja JavaScript engine for server-side extensions. Code runs inside the PocketBase process — synchronous, no Node/Browser APIs.
Version split — read this first
v0.23 is a breaking JSVM boundary. PocketHost runs both legacy (≤ v0.22) and modern (≥ v0.23) instances. Always confirm the instance PocketBase version before writing or porting hooks — do not mix APIs across versions.
| ≤ v0.22 | ≥ v0.23 | |
|---|---|---|
| Overview | old/js-overview | js-overview |
| API reference | old/jsvm | jsvm |
| Typings (PocketHost) | packages/pockethost/src/instance-app/v22/types/types.d.ts |
pb_data/types.d.ts on instance; PocketHost templates in instance-app/v23/ |
| Record access | $app.dao().findRecordById(...) |
$app.findRecordById(...) |
| Startup hook | $app.onBeforeServe().add((e) => { ... }) or onAfterBootstrap |
onBootstrap((e) => { e.next(); ... }) |
| Custom routes | (c) =>, c.pathParam('id'), path /api/foo/:id |
(e) =>, e.request.pathValue('id'), path /api/foo/{id} |
| Record hooks | onRecordAfterCreateRequest, … |
onRecordAfterCreateSuccess, … (call e.next()) |
| Admin auth table | _admins (passwordHash) |
_superusers (password) |
| Admin UI plugins | N/A | $app.onServe() → e.uiExtensions (PB ≥0.37, experimental) — pocketbase-admin-plugins |
When in doubt, open the JSVM reference for that version — hook names, route handler signatures, and $app methods differ. For mothership v0.39 port work, read v023-upgrade.md and the official JSVM upgrade guide.
Pre-flight checklist
Before writing hook code, verify:
- Target PocketBase version — use the matching JSVM docs (≤ v0.22 vs ≥ v0.23)
- No
async/await, Promises, or.then() - No
fetch,setTimeout,setInterval, DOM APIs - No Node built-ins (
fs,http,path, etc.) - Use
require()for modules (CommonJS only) - Use
$app/ record APIs — notnew PocketBase() - External HTTP via
$http.send()(sync), notfetch -
$app.store()values that cross requests: JSON.stringify/parse at boundaries — never mutateget()results in place (app-store.md) - Json fields, hook routers, client errors: skim quirks.md if behavior looks like Node
For full environment constraints, see constraints.md. Goja behavioral quirks (toString, hoisting, json fields, error sanitization): quirks.md.
File layout
pb_hooks/
├── main.pb.js # auto-loaded entry hooks
├── config/
│ └── config.js # shared module (require target)
└── posts.create.pb.js
- Only
*.pb.jsfiles are auto-loaded as hook entry points. - Shared modules: plain
.jsfiles loaded viarequire(). - Use
${__hooks}for the hooks directory path:
const config = require(`${__hooks}/config/config.js`)
Hook file hot reload (in-process)
PocketBase watches hook JS on disk (pb_hooks/*.pb.js and files loaded via require()). When they change, it reloads hook code inside the running process — the PocketBase process does not exit. This is separate from PM2 or Docker restarts.
Operational implications:
- Do not
git checkout, rsync, or deploy while PocketBase is running if the operation swaps hook files incrementally. A mid-checkout tree can be picked up and loaded as a torn mix of old and new code. - Mothership: stop before branch switches or bulk hook deploys (e.g.
v39.sh --forwardkeeps mothership stopped until checkout finishes, thenpm2 reload). - Customer instances on PocketHost: FTP/phio deploy restarts the instance container so hooks reload from a consistent tree — different mechanism, same “don’t run torn hooks” goal.
Hook categories
Names differ by version — check the JSVM reference for your target.
| Category | ≤ v0.22 examples | ≥ v0.23 examples | Purpose |
|---|---|---|---|
| Bootstrap | onAfterBootstrap, $app.onBeforeServe().add |
onBootstrap (+ e.next()) |
Startup initialization |
| HTTP routes | routerAdd(method, path, handler, ...middlewares) |
same global, different handler arg | Custom API endpoints |
| Record hooks | onRecordBeforeCreateRequest, onRecordAfterCreateRequest |
onRecordBeforeCreateRequest, onRecordAfterCreateSuccess, … |
Validate/transform on CRUD |
| Model hooks | onModelBeforeUpdate, onModelAfterCreate |
still available — verify in JSVM ref | Lower-level DAO events |
| Cron | cronAdd(id, expr, handler), cronRemove(id) |
same | Scheduled jobs |
| Middleware | routerUse(...) |
same | Global route middleware |
Collection-scoped hooks take the collection name/id as the last argument:
onRecordAfterCreateRequest((e) => {
const record = e.record
// ...
}, 'users')
Custom routes
≤ v0.22 — Echo-style context c, colon params:
routerAdd('POST', '/test/:testId', (c) => {
const testId = c.pathParam('testId')
return c.json(200, { testId })
})
≥ v0.23 — request event e, brace params:
routerAdd('POST', '/test/{testId}', (e) => {
const testId = e.request.pathValue('testId')
return e.json(200, { testId })
})
With auth middleware (≤ v0.22 mothership pattern):
routerAdd('PUT', '/api/instance/:id', (c) => {
return require(`${__hooks}/mothership`).HandleInstanceUpdate(c)
}, $apis.requireRecordAuth())
Request body (≥ v0.23) — use e.bindBody() + DynamicModel, not JSON.parse(readerToString(e.request.body)):
let data = new DynamicModel({ trusted_ips: [] })
e.bindBody(data)
data = JSON.parse(JSON.stringify(data)) // required before destructuring / $common validators
const { trusted_ips } = data
Quick reads: e.requestInfo().body. Raw stream: readerToString(e.request.body) only for webhooks / signature verification.
Full guide: request-body.md (includes BadRequestError wrapping for client-visible validation errors). Official docs: Reading request body.
Request body (≤ v0.22):
const body = $apis.requestInfo(c).data
Clients call custom routes via pb.send() — see pocketbase-js-sdk.
Record operations
≥ v0.23 — direct $app methods:
routerAdd('PATCH', '/posts/{postId}', (e) => {
const postId = e.request.pathValue('postId')
let data = new DynamicModel({ status: '' })
e.bindBody(data)
data = JSON.parse(JSON.stringify(data))
const record = $app.findRecordById('posts', postId)
record.set('status', data.status)
$app.save(record)
return e.json(200, { record })
})
≤ v0.22 — go through $app.dao():
routerAdd('PATCH', '/posts/:postId', (c) => {
const postId = c.pathParam('postId')
const { status } = $apis.requestInfo(c).data
const record = $app.dao().findRecordById('posts', postId)
record.set('status', status)
$app.dao().saveRecord(record)
return c.json(200, { record })
})
Raw SQL when needed:
$app.db().newQuery('SELECT * FROM posts WHERE id = {:id}')
.bind({ id: postId })
.one()
External HTTP
Use synchronous $http.send():
const res = $http.send({
url: 'https://api.example.com/webhook',
method: 'POST',
body: { email: record.get('email') },
headers: { Authorization: 'Bearer ...' },
})
Environment variables
Only process.env is shimmed:
const value = process.env.MY_SECRET || ''
PocketHost injects env vars (e.g. ADMIN_SYNC in instance hooks).
Modules
// pb_hooks/utils.js
module.exports = {
mkLog: (ns) => (...args) => console.log(`[${ns}]`, ...args),
}
// pb_hooks/main.pb.js
const { mkLog } = require(`${__hooks}/utils.js`)
const log = mkLog('main')
PocketHost specifics
- User hooks: upload to
pb_hooks/via FTP; changes restart the instance. - Instance templates:
packages/pockethost/src/instance-app/v22/(≤ v0.22) andv23/(≥ v0.23) — compare_ph_admin_sync.pb.jsfor a side-by-side API diff. - Mothership hooks (≥ v0.23 / v0.39):
packages/pockethost/src/mothership-app/pb_hooks/— port guide in v023-upgrade.md. Source issrc/lib/handlers/; see .cursor/rules/mothership-hooks.mdc - Typings:
instance-app/v22/types/types.d.ts(legacy instances);mothership-app/src/types/types.d.ts(control plane) - PocketHost sandbox may restrict
$os— avoid OS-level calls.
Mothership hook build boundary
Handlers compile with tsdown into pb_hooks/mothership.js and run in Goja, not Node.
- Shared hook logic lives in
packages/pockethost/src/common/— must be JSVM-safe (sync, no Node/browser APIs). - Import via
$common/<file>subpaths (tsconfig"$common/*": ["../common/*"]). Avoid runtime imports from the$commonbarrel. - After build, confirm no
[UNRESOLVED_IMPORT]warnings — unresolved imports break validation silently at runtime.
// ✅ validateSshKey.ts — subpath bundles sshPublicKey.ts only
import { parseSshEd25519PublicKey } from '$common/sshPublicKey'
// ❌ tsdown won't resolve without tsconfig path
import { parseSshEd25519PublicKey } from 'pockethost/common'
// ❌ pulls unrelated common modules into pb_hooks
import { parseSshEd25519PublicKey } from '$common'
$app.store() concurrency
Concurrent hooks must not share live Goja objects via $app.store(). Use JSON string boundaries and setFunc for atomic read-modify-write. See app-store.md.
Examples
See hooks-examples.md for copy-paste patterns from this repo and PocketHost docs.
API reference
- Pick the JSVM reference for the target version (see Version split above).
- Cross-check generated typings — v22:
instance-app/v22/types/types.d.ts; mothership:mothership-app/src/types/types.d.ts. - On a running instance,
pb_data/types.d.tsmatches that instance's PocketBase version.