# Pocketbase Jsvm

> Writes PocketBase server-side JavaScript in the Goja JSVM: pb_hooks, routerAdd, record hooks, cron jobs, and synchronous $app APIs. Use for *.pb.js files, pb_hooks, pb_migrations JS, or server-side PocketBase extensions — not the npm JS SDK. Read quirks.md for Goja gotchas (toString, json fields, hoisting, BadRequestError). Always check target PocketBase version: v0.22 and v0.23+ use different JSVM APIs and docs. PocketBase watches pb_hooks JS on disk and hot-reloads hooks inside the running process without exiting.

- Skill: `pockethost/pocketbase-jsvm` (Agent Skill, multi-file: 7 files)
- Install (CLI): `npx skillmds@latest add pockethost/pocketbase-jsvm`
- Raw SKILL.md: https://api.skillmd.com/api/skills/pockethost/pocketbase-jsvm/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: pockethost (https://skillmd.com/u/pockethost)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/pockethost/pocketbase-jsvm

---


# 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](https://pocketbase.io/old/docs/js-overview/) | [js-overview](https://pocketbase.io/docs/js-overview/) |
| **API reference** | [old/jsvm](https://pocketbase.io/old/jsvm/) | [jsvm](https://pocketbase.io/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](v023-upgrade.md) and the [official JSVM upgrade guide](https://pocketbase.io/v023upgrade/jsvm/).

## 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 — not `new PocketBase()`
- [ ] External HTTP via `$http.send()` (sync), not `fetch`
- [ ] `$app.store()` values that cross requests: **JSON.stringify/parse** at boundaries — never mutate `get()` results in place ([app-store.md](app-store.md))
- [ ] Json fields, hook routers, client errors: skim [quirks.md](quirks.md) if behavior looks like Node

For full environment constraints, see [constraints.md](constraints.md). **Goja behavioral quirks** (toString, hoisting, json fields, error sanitization): [quirks.md](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.js` files are auto-loaded as hook entry points.
- Shared modules: plain `.js` files loaded via `require()`.
- Use `${__hooks}` for the hooks directory path:

```js
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 --forward` keeps mothership stopped until checkout finishes, then `pm2 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:

```js
onRecordAfterCreateRequest((e) => {
  const record = e.record
  // ...
}, 'users')
```

## Custom routes

**≤ v0.22** — Echo-style context `c`, colon params:

```js
routerAdd('POST', '/test/:testId', (c) => {
  const testId = c.pathParam('testId')
  return c.json(200, { testId })
})
```

**≥ v0.23** — request event `e`, brace params:

```js
routerAdd('POST', '/test/{testId}', (e) => {
  const testId = e.request.pathValue('testId')
  return e.json(200, { testId })
})
```

With auth middleware (≤ v0.22 mothership pattern):

```js
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))`:

```js
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](request-body.md) (includes **`BadRequestError` wrapping** for client-visible validation errors). Official docs: [Reading request body](https://pocketbase.io/docs/js-routing/#reading-request-body).

Request body (≤ v0.22):

```js
const body = $apis.requestInfo(c).data
```

Clients call custom routes via `pb.send()` — see **pocketbase-js-sdk**.

## Record operations

**≥ v0.23** — direct `$app` methods:

```js
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()`:

```js
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:

```js
$app.db().newQuery('SELECT * FROM posts WHERE id = {:id}')
  .bind({ id: postId })
  .one()
```

## External HTTP

Use synchronous `$http.send()`:

```js
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:

```js
const value = process.env.MY_SECRET || ''
```

PocketHost injects env vars (e.g. `ADMIN_SYNC` in instance hooks).

## Modules

```js
// pb_hooks/utils.js
module.exports = {
  mkLog: (ns) => (...args) => console.log(`[${ns}]`, ...args),
}
```

```js
// 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) and `v23/` (≥ v0.23) — compare `_ph_admin_sync.pb.js` for 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](v023-upgrade.md). **Source** is `src/lib/handlers/`; see [.cursor/rules/mothership-hooks.mdc](../../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.

1. Shared hook logic lives in `packages/pockethost/src/common/` — must be **JSVM-safe** (sync, no Node/browser APIs).
2. Import via `$common/<file>` subpaths (tsconfig `"$common/*": ["../common/*"]`). Avoid runtime imports from the `$common` barrel.
3. After build, confirm **no** `[UNRESOLVED_IMPORT]` warnings — unresolved imports break validation silently at runtime.

```typescript
// ✅ 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](app-store.md).

## Examples

See [hooks-examples.md](hooks-examples.md) for copy-paste patterns from this repo and PocketHost docs.

## API reference

1. Pick the **JSVM reference for the target version** (see [Version split](#version-split--read-this-first) above).
2. Cross-check generated typings — v22: `instance-app/v22/types/types.d.ts`; mothership: `mothership-app/src/types/types.d.ts`.
3. On a running instance, `pb_data/types.d.ts` matches that instance's PocketBase version.

