@voidbase-cloud/voidbase 0.2.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README.md +15 -7
  3. package/bin/voidbase.ts +58 -6
  4. package/docs/adapter.md +271 -0
  5. package/docs/ci.md +195 -0
  6. package/docs/deploy.md +60 -5
  7. package/docs/releasing.md +39 -31
  8. package/hooks-plugin.ts +15 -4
  9. package/package.json +9 -4
  10. package/routes/api/[...path].ts +0 -5
  11. package/scripts/cf-builds.ts +228 -0
  12. package/scripts/ci-browser.sh +60 -0
  13. package/scripts/ci-cache.sh +40 -0
  14. package/scripts/ci-lib.sh +40 -0
  15. package/scripts/ci-oracles.sh +19 -0
  16. package/scripts/ci-plan.ts +270 -0
  17. package/scripts/ci-status.ts +126 -0
  18. package/scripts/ci-suites.sh +11 -1
  19. package/scripts/ci.sh +188 -0
  20. package/scripts/gh-release.ts +48 -0
  21. package/scripts/release.sh +104 -0
  22. package/scripts/seed-reference.sh +7 -2
  23. package/scripts/sync-app.ts +1 -0
  24. package/src/adapter/bundle.ts +130 -0
  25. package/src/adapter/codegen.ts +269 -0
  26. package/src/adapter/index.ts +6 -0
  27. package/src/adapter/plugin.ts +117 -0
  28. package/src/adapter/runtime.ts +325 -0
  29. package/src/adapter/scan.ts +276 -0
  30. package/src/cloud/rest.ts +10 -2
  31. package/src/node/assets.ts +7 -1
  32. package/src/node/cloud-init.ts +14 -0
  33. package/src/node/deploy-cf.ts +113 -16
  34. package/src/node/secrets.ts +169 -0
  35. package/src/node/serve.ts +15 -2
  36. package/src/server/api.ts +7 -2
  37. package/src/server/app.ts +6 -1
  38. package/src/server/hooks/index.ts +27 -1
  39. package/src/server/hooks/migrations.ts +4 -1
  40. package/src/server/hooks/runtime.ts +10 -2
  41. package/src/server/jobs.ts +3 -1
  42. package/src/server/webauthn.ts +23 -6
  43. package/tsconfig.json +4 -0
  44. package/tsconfig.node.json +2 -1
package/CHANGELOG.md CHANGED
@@ -3,6 +3,53 @@
3
3
  Entries after 0.1.0 are compiled by release-please from the Conventional Commits merged since the previous release
4
4
  (docs/releasing.md); 0.1.0 was written by hand.
5
5
 
6
+ ## [0.3.0](https://github.com/voidbase-cloud/voidbase/compare/v0.2.2...v0.3.0) (2026-09-07)
7
+
8
+
9
+ ### Features
10
+
11
+ * **adapter:** build a Void app into a voidbase app ([e1e221b](https://github.com/voidbase-cloud/voidbase/commit/e1e221b1df4b70b6b513a4ce5c665ed73a40efe8))
12
+ * **adapter:** compile routes, middleware, crons and queues into pb_hooks ([3837897](https://github.com/voidbase-cloud/voidbase/commit/383789715e53a3467a0f7203df06bcd5b31fa491))
13
+ * **adapter:** generate a whole voidbase app into .voidbase/ ([d11db67](https://github.com/voidbase-cloud/voidbase/commit/d11db674fea39954ca8afe8844ec2108040bd944))
14
+ * **adapter:** PocketBase's API inside a Void route ([d6008cc](https://github.com/voidbase-cloud/voidbase/commit/d6008cc0d34a8a857965725efc83dcc76c4c8862))
15
+ * **adapter:** vb_hooks/ and vb_migrations/ as project-root source ([fd861b0](https://github.com/voidbase-cloud/voidbase/commit/fd861b05afcc744481a0453a40a54149d1cd0c7e))
16
+ * **adapter:** vb_hooks/ for PocketBase's event hooks, middleware/ through routerUse ([2baad03](https://github.com/voidbase-cloud/voidbase/commit/2baad03f6f03c57cd98d036b3e8ede6b9d5fe2d0))
17
+ * **adapter:** vb_secrets/, the secrets a Void app declares ([a1fda2b](https://github.com/voidbase-cloud/voidbase/commit/a1fda2b4e17029b1db4380cfa118545b3e0efca8))
18
+ * **deploy:** _redirects in the public dir become Void edge redirects ([8c43427](https://github.com/voidbase-cloud/voidbase/commit/8c434270bcf7e92731d90f1aaaee9c3cea3b89e6))
19
+ * **deploy:** _redirects in the public dir become Void edge redirects ([bf873b4](https://github.com/voidbase-cloud/voidbase/commit/bf873b4ef45806646ee4ee8a80ecbbba5e70307c))
20
+ * **deploy:** host-scoped _redirects become zone Redirect Rules ([cd1f8f7](https://github.com/voidbase-cloud/voidbase/commit/cd1f8f7e0080e55656a905c2c861eb923f624be7))
21
+ * **deploy:** pb_secrets/, the app's secrets declared in git and valued outside it ([da8e4c6](https://github.com/voidbase-cloud/voidbase/commit/da8e4c636ed1b0b67cbfd61b6b6f2030fbdaaa08))
22
+ * **deploy:** serve ./pb_public by default and attach several custom domains ([c8d39bd](https://github.com/voidbase-cloud/voidbase/commit/c8d39bd77ee5ef8e9a2a1c40348ea2715c6c4c18))
23
+ * **deploy:** VOIDBASE_DEPLOY_ZONE_TOKEN for the zone redirect rules ([6013d48](https://github.com/voidbase-cloud/voidbase/commit/6013d485be4886f25096229d74309993a2260e16))
24
+ * **hooks:** routerUse is PocketBase's global middleware, around every request ([12eaa8a](https://github.com/voidbase-cloud/voidbase/commit/12eaa8ab43214d72c225dc25ffb8411b2838978f))
25
+ * **serve:** resolve an extensionless path against <path>.html ([5b7bb26](https://github.com/voidbase-cloud/voidbase/commit/5b7bb26b006d0c0cd5573d1cde99cd457175e5b4))
26
+
27
+
28
+ ### Bug Fixes
29
+
30
+ * **adapter:** report once per build, not once per Vite environment ([a593b1b](https://github.com/voidbase-cloud/voidbase/commit/a593b1b55a99be6fe1b7cd8b148b1d6c357f48c0))
31
+ * **adapter:** the generated entry finds its own directories ([3ec53cf](https://github.com/voidbase-cloud/voidbase/commit/3ec53cfe7039cd95efece83f2caf535626b56027))
32
+ * **auth:** reject non-canonical base64url token segments like PocketBase ([7d00575](https://github.com/voidbase-cloud/voidbase/commit/7d005752598e640663dfa7cfa69321032ceb7fd2))
33
+ * **ci:** read every pushed commit on a shallow checkout ([11acdf3](https://github.com/voidbase-cloud/voidbase/commit/11acdf39b040c8b280e4d5e292db4ddb24aa3060))
34
+ * **cloud:** detach the queue consumer before destroying an instance ([2eb5797](https://github.com/voidbase-cloud/voidbase/commit/2eb579797351812b8322582f99a9731556947466))
35
+ * **deploy:** a deploy never replaces a secret the Worker already has ([63cc651](https://github.com/voidbase-cloud/voidbase/commit/63cc65148b4bf2bc229c1ec4e7a8a5d2c20e4cfd))
36
+ * **deploy:** one token; name the zone permission the redirect rules need ([16d0537](https://github.com/voidbase-cloud/voidbase/commit/16d0537589e6ca354b4220f50600d0569c2a12de))
37
+ * **deploy:** secrets.json outranks the .env files ([9df1802](https://github.com/voidbase-cloud/voidbase/commit/9df180212dc0027210cb6f589856da972fa3cce9))
38
+
39
+
40
+ ### Performance
41
+
42
+ * **auth:** passkey routes in every app, gated on a passkeys collection, loaded on first use ([77af85e](https://github.com/voidbase-cloud/voidbase/commit/77af85e447fd971be4e06e272799268982f88eca))
43
+
44
+
45
+ ### Documentation
46
+
47
+ * **ci:** the reference is seeded fresh per run and before the Bun pass ([d3f6d0c](https://github.com/voidbase-cloud/voidbase/commit/d3f6d0c29b35e42b2738faf2f258e9177bebe17e))
48
+ * **deploy:** the site's control plane moved to voidbase-site/cloud ([66f2e8c](https://github.com/voidbase-cloud/voidbase/commit/66f2e8c41fe4f66123534e338610d0975d2dcb10))
49
+ * **surface:** CI live on Cloudflare with incremental runs ([82a5461](https://github.com/voidbase-cloud/voidbase/commit/82a5461b0af283efa34f32c02c0691c12b927bff))
50
+ * **surface:** one CI project on Cloudflare, hot mode and incremental runs ([4665543](https://github.com/voidbase-cloud/voidbase/commit/46655431b081677d1e461f78977ceac1886d94fa))
51
+ * **surface:** the Void adapter ([0228acb](https://github.com/voidbase-cloud/voidbase/commit/0228acb4d7e8105a864c32a48db80bc0f68b3ff1))
52
+
6
53
  ## [0.2.2](https://github.com/voidbase-cloud/voidbase/compare/v0.2.1...v0.2.2) (2026-09-06)
7
54
 
8
55
 
package/README.md CHANGED
@@ -1,5 +1,7 @@
1
1
  # voidbase
2
2
 
3
+ [![ci](https://release.voidbase.cloud/badge.svg)](https://release.voidbase.cloud/) The status page of the last green build: every step, every suite, the screenshots.
4
+
3
5
  A PocketBase-wire-compatible backend on Cloudflare Workers, built with [Void](https://void.cloud).
4
6
  The unmodified PocketBase admin panel (0.40.2) and the unmodified `pocketbase` JS SDK (0.28) are the two oracles that define done.
5
7
 
@@ -36,8 +38,8 @@ platform, verifies its checksum and replaces the executable (`--backup` zips `pb
36
38
  toolchain (`deploy`, `bundle`, `dev`) stays with the npm package.
37
39
 
38
40
  Commits follow Conventional Commits (enforced by husky and CI); release-please turns them into a release PR,
39
- and merging it publishes to npm (with provenance once the repository is public) and GitHub Packages with the
40
- compiled notes; see [docs/releasing.md](docs/releasing.md).
41
+ and merging it publishes to npm and GitHub Packages and attaches the executables, with the compiled notes; see
42
+ [docs/releasing.md](docs/releasing.md).
41
43
 
42
44
  ## Run it like PocketBase
43
45
 
@@ -101,14 +103,20 @@ provider variables.
101
103
 
102
104
  `voidbase token` prints a Cloudflare dashboard link that creates `VOIDBASE_DEPLOY_CF_API_KEY` with the right
103
105
  permissions pre-selected; with that variable set, `voidbase deploy` provisions D1 and R2, generates the Void project
104
- inside the package (`node_modules/voidbase/.cloud/<name>`), stores the superuser as secrets and uploads the Worker.
105
- Your directory stays `pb_hooks` + `pb_migrations` + `pb_data`, like a PocketBase folder. See [docs/deploy.md](docs/deploy.md).
106
+ inside the package (`node_modules/voidbase/.cloud/<name>`), stores the superuser and the secrets `pb_secrets/`
107
+ declares as the Worker's secrets, and uploads the Worker. Your directory stays `pb_hooks` + `pb_migrations` +
108
+ `pb_secrets` + `pb_data`, like a PocketBase folder: `pb_secrets/main.pb.js` names the secrets, the git-ignored
109
+ `pb_secrets/secrets.json` holds them, and CI deploys with nothing but the deploy token once `voidbase secrets push`
110
+ has stored them. See [docs/deploy.md](docs/deploy.md).
106
111
 
107
112
  ## Continuous integration
108
113
 
109
- `.github/workflows/ci.yml` checks out the two oracles (the starter and PocketBase's panel build), starts voidbase and
110
- a seeded reference PocketBase (`scripts/seed-reference.sh`), and runs every suite through `scripts/ci-suites.sh`,
111
- then `test/fresh-db.ts` and the starter smoke. The same scripts run locally against any pair of servers.
114
+ `scripts/ci.sh` is the whole CI (`bun run ci` on a dev machine): it fetches the starter, syncs the panel, starts
115
+ voidbase and a seeded reference PocketBase (`scripts/seed-reference.sh`), runs every suite through
116
+ `scripts/ci-suites.sh` on the Workers and Bun runtimes, then the production-build boots, the prebuilt executable and
117
+ the starter smoke, and renders the status page at [release.voidbase.cloud](https://release.voidbase.cloud/).
118
+ Cloudflare Workers Builds runs it (`scripts/cf-builds.ts setup`); `.github/workflows/cloudflare.yml` only starts the
119
+ builds; see [docs/ci.md](docs/ci.md).
112
120
 
113
121
  ## Docs
114
122
 
package/bin/voidbase.ts CHANGED
@@ -18,26 +18,33 @@ const flags: Record<string, string> = {}; const positional: string[] = [];
18
18
  for (let i = 0; i < argv.length; i++) { const a = argv[i]!; if (a.startsWith("--")) { const [k, v] = a.slice(2).split("="); flags[k!] = v ?? (argv[i + 1] && !argv[i + 1]!.startsWith("--") ? argv[++i]! : "1"); } else positional.push(a); }
19
19
  const [cmd, sub, ...rest] = positional;
20
20
  const url = (flags.url ?? process.env.VOIDBASE_URL ?? "http://127.0.0.1:8090").replace(/\/$/, "");
21
- const serveOpts = () => ({ http: flags.http, dir: flags.dir, hooksDir: flags.hooksDir, migrationsDir: flags.migrationsDir, publicDir: flags.publicDir });
21
+ const serveOpts = () => ({ http: flags.http, dir: flags.dir, hooksDir: flags.hooksDir, migrationsDir: flags.migrationsDir, secretsDir: flags.secretsDir, publicDir: flags.publicDir });
22
22
  const admin = () => { const [email, password] = (flags.admin ?? `${process.env.VOIDBASE_SUPERUSER_EMAIL ?? "admin@example.com"}:${process.env.VOIDBASE_SUPERUSER_PASSWORD ?? ""}`).split(":") as [string, string]; return { email, password }; };
23
23
  const HELP = `voidbase - PocketBase-compatible backend: a single Bun process locally, Cloudflare Workers via Void in production
24
24
 
25
- serve [--http 127.0.0.1:8090] [--dir pb_data] [--hooksDir pb_hooks] [--migrationsDir pb_migrations] [--publicDir ../sk/build] [--dev] [--entry main.ts]
25
+ serve [--http 127.0.0.1:8090] [--dir pb_data] [--hooksDir pb_hooks] [--migrationsDir pb_migrations] [--secretsDir pb_secrets] [--publicDir pb_public] [--dev] [--entry main.ts]
26
26
  run the server like "pocketbase serve" (--dev restarts when hooks or migrations change;
27
27
  --entry runs your own main.ts, the counterpart of a custom PocketBase build)
28
28
  superuser upsert <email> <password> create or update a superuser: on the local data directory (--dir) or on a running
29
29
  instance (--url, --admin email:pass)
30
30
 
31
- init [dir] scaffold .env, pb_hooks/, pb_migrations/ in a fresh checkout and sync the panel
31
+ adapt [dir] [--public-dir .voidbase/pb_public] [--no-migrations]
32
+ generate a voidbase app under .voidbase/ from a Void app: PocketBase's layout
33
+ (main.ts, package.json, pb_hooks, pb_migrations, pb_public) with the project's
34
+ routes/middleware/crons/queues and whatever src/voidbase/ adds. vite build does
35
+ this too through the voidbaseAdapter() plugin; this is the same pass without Vite.
36
+ init [dir] scaffold .env, pb_hooks/, pb_migrations/, pb_secrets/ in a fresh checkout and sync the panel
32
37
  dev [--port 5180] start the Void dev server (vp dev)
33
38
  build | preview [--port 5181] production build / run the built Worker locally (vp build / vp preview)
34
- deploy [--name worker] [--account id] [--domain api.example.com] [--public-dir ../sk/build] [--dry-run] [--no-queue] [--no-hub] [--no-cron]
39
+ deploy [--name worker] [--account id] [--domain example.com,api.example.com] [--public-dir pb_public] [--dry-run] [--no-queue] [--no-hub] [--no-cron]
35
40
  [--analytics] [--rate-limit 300/10]
36
41
  go live on your Cloudflare account with VOIDBASE_DEPLOY_CF_API_KEY: creates the D1
37
42
  database and R2 bucket, writes cloud/ (voidbase cloud init) with wrangler.jsonc,
38
43
  stores the superuser as worker secrets and runs void deploy --backend cloudflare
39
44
  deploy --void deploy to the Void platform instead (void auth login first)
40
45
  token print the Cloudflare dashboard link that creates VOIDBASE_DEPLOY_CF_API_KEY
46
+ secrets [list] [--dir pb_secrets] the secrets pb_secrets/main.pb.js declares: which have a value in secrets.json (git-ignored)
47
+ secrets push [--name worker] and which are on the Worker; push stores the local values as the Worker's secrets
41
48
  update [--dir pb_data] [--backup] prebuilt executable only: fetch the latest GitHub release for this platform, verify
42
49
  its checksum and replace the executable (--backup zips pb_data first)
43
50
  version print the version
@@ -78,6 +85,31 @@ switch (cmd) {
78
85
  await update({ currentVersion: await currentVersion(), dataDir: resolve(flags.dir ?? "pb_data"), backup: !!flags.backup });
79
86
  break;
80
87
  }
88
+ case "secrets": {
89
+ // pb_secrets/ (src/node/secrets.ts): what is declared, what has a value here, what the Worker has; push the values
90
+ const { secretsState, putWorkerSecrets, workerSecretNames, SECRETS_DIR } = await import("../src/node/secrets");
91
+ const { deployTarget } = await import("../src/node/deploy-cf");
92
+ const dir = resolve(flags.dir ?? process.env.VOIDBASE_SECRETS_DIR ?? SECRETS_DIR);
93
+ const state = secretsState(dir);
94
+ if (!state.declaration) { console.log(`${dir}/main.pb.js does not exist: no secrets declared (voidbase init writes one)`); break; }
95
+ if (sub === "push") {
96
+ const { api, account, name } = await deployTarget({ name: flags.name, account: flags.account, log: () => undefined });
97
+ const values: Record<string, string> = {}; for (const k of state.provided) values[k] = state.values![k]!;
98
+ if (!Object.keys(values).length) { console.log(`nothing to push: none of ${state.declaration.names.join(", ")} has a value in ${dir}/secrets.json`); break; }
99
+ const before = await workerSecretNames(api, account.id, name);
100
+ const done = await putWorkerSecrets(api, account.id, name, values).catch((e: Error) => { throw new Error(`${e.message}\n (the Worker "${name}" must exist: voidbase deploy creates it and stores the secrets itself)`); });
101
+ console.log(`pushed ${done.length} secret(s) to worker "${name}" (account ${account.name}): ${done.map((k) => `${k}${before.includes(k) ? " (replaced)" : ""}`).join(", ")}`);
102
+ if (state.unprovided.length) console.log(`no local value, left as they are: ${state.unprovided.join(", ")}`);
103
+ break;
104
+ }
105
+ // list (default): a row per declared name
106
+ let onWorker: string[] | null = null; let worker = "";
107
+ try { const t = await deployTarget({ name: flags.name, account: flags.account, log: () => undefined }); worker = t.name; onWorker = await workerSecretNames(t.api, t.account.id, t.name); } catch { /* no token here: local view only */ }
108
+ console.log(`${dir}: ${state.declaration.names.length} declared${state.values ? `, ${state.provided.length} valued in secrets.json` : ", no secrets.json"}${onWorker ? `, worker "${worker}" has ${onWorker.filter((k) => state.declaration!.names.includes(k)).length} of them` : " (set VOIDBASE_DEPLOY_CF_API_KEY to compare with the Worker)"}`);
109
+ for (const k of state.declaration.names) console.log(` ${k.padEnd(32)} ${state.provided.includes(k) ? "local value" : "no local value"}${onWorker ? ` ${onWorker.includes(k) ? "on the worker" : "NOT on the worker"}` : ""}${state.declaration.descriptions[k] ? ` ${state.declaration.descriptions[k]}` : ""}`);
110
+ if (state.undeclared.length) console.log(` in secrets.json but not declared (never deployed): ${state.undeclared.join(", ")}`);
111
+ break;
112
+ }
81
113
  case "token": { const { tokenHelp } = await import("../src/node/deploy-cf"); console.log(tokenHelp()); break; }
82
114
  case "bundle": {
83
115
  const { buildRelease, pushRelease } = await import("../src/node/bundle");
@@ -108,12 +140,32 @@ switch (cmd) {
108
140
  await new Promise(() => undefined);
109
141
  break;
110
142
  }
143
+ case "adapt": {
144
+ const { adapt } = await import("../src/adapter/index");
145
+ const root = resolve(sub ?? ".");
146
+ const { manifest, written, copied } = await adapt(root, { publicDir: flags["public-dir"], migrations: !flags["no-migrations"], quiet: true });
147
+ for (const u of manifest.unsupported) console.warn(`not carried over: ${u.what} — ${u.why}`);
148
+ for (const c of manifest.collisions) console.warn(`shadowed by voidbase's own API, the app route never runs: ${c}`);
149
+ console.log(`${manifest.mode === "static" ? "static" : "server"} app at ${root}`);
150
+ console.log(` ${manifest.routes.length} route(s), ${manifest.middleware.length} middleware, ${manifest.hooks.length} hook(s), ${manifest.crons.length} cron(s), ${manifest.queues.length} queue(s), ${manifest.migrations.length} migration(s)${manifest.secrets ? `, ${manifest.secrets.names.length} secret(s)` : ""}`);
151
+ console.log(` wrote ${written.join(", ") || "nothing"}${copied ? `, ${copied} entr(ies) into ${flags["public-dir"] ?? ".voidbase/pb_public"}` : ""}`);
152
+ console.log(" run it: bun .voidbase/main.ts --http 127.0.0.1:8090");
153
+ console.log(" deploy it: cd .voidbase && voidbase deploy");
154
+ break;
155
+ }
111
156
  case "init": {
112
157
  const dir = resolve(sub ?? ".");
113
- mkdirSync(`${dir}/pb_hooks`, { recursive: true }); mkdirSync(`${dir}/pb_migrations`, { recursive: true });
158
+ mkdirSync(`${dir}/pb_hooks`, { recursive: true }); mkdirSync(`${dir}/pb_migrations`, { recursive: true }); mkdirSync(`${dir}/pb_secrets`, { recursive: true });
159
+ { // pb_secrets/main.pb.js declares the secrets; secrets.json holds their values and never enters git
160
+ const { declarationScaffold } = await import("../src/node/secrets");
161
+ if (!existsSync(`${dir}/pb_secrets/main.pb.js`)) writeFileSync(`${dir}/pb_secrets/main.pb.js`, declarationScaffold());
162
+ const gi = `${dir}/.gitignore`; const have = existsSync(gi) ? await Bun.file(gi).text() : "";
163
+ const lines = ["pb_data/", "pb_secrets/secrets.json"].filter((l) => !have.split("\n").some((x) => x.trim() === l || x.trim() === l.replace(/\/$/, "")));
164
+ if (lines.length) writeFileSync(gi, `${have}${have && !have.endsWith("\n") ? "\n" : ""}${lines.join("\n")}\n`);
165
+ }
114
166
  if (!existsSync(`${dir}/.env`)) { cpSync(`${ROOT}/.env.example`, `${dir}/.env`); console.log("wrote .env from .env.example (set VOIDBASE_SUPERUSER_EMAIL/PASSWORD)"); }
115
167
  if (!existsSync(`${dir}/pb_hooks/main.pb.js`)) writeFileSync(`${dir}/pb_hooks/main.pb.js`, `/// <reference path="../pb_data/types.d.ts" />\nrouterAdd("GET", "/api/hello", (e) => e.json(200, { hello: "voidbase" }));\n`);
116
- console.log("pb_hooks/ and pb_migrations/ ready");
168
+ console.log("pb_hooks/, pb_migrations/ and pb_secrets/ ready (.gitignore covers pb_data/ and pb_secrets/secrets.json)");
117
169
  await run("bun", ["scripts/sync-panel.ts"]).catch(() => undefined);
118
170
  console.log("\nnext: bun install && ./node_modules/.bin/void db migrate && voidbase dev");
119
171
  break;
@@ -0,0 +1,271 @@
1
+ # Running a Void app on voidbase
2
+
3
+ A [Void](https://void.cloud) app and a voidbase app are shaped differently. Void puts server code in `routes/`,
4
+ `middleware/`, `crons/` and `queues/` and builds a Cloudflare Worker; voidbase is a PocketBase project, with the
5
+ site in `pb_public/`, JS hooks in `pb_hooks/` and migrations in `pb_migrations/`. The adapter keeps the project a
6
+ plain Void app and *generates* the voidbase one from it, whole, into a git-ignored `.voidbase/`.
7
+
8
+ ```ts
9
+ // vite.config.ts
10
+ import { defineConfig } from "vite";
11
+ import { voidPlugin } from "void";
12
+ import { voidbaseAdapter } from "@voidbase-cloud/voidbase/adapter/plugin";
13
+
14
+ export default defineConfig({ plugins: [voidPlugin(), voidbaseAdapter()] });
15
+ ```
16
+
17
+ `vite build` then writes [PocketBase's minimal layout](https://pocketbase.io/docs/going-to-production/#minimal-setup):
18
+
19
+ ```
20
+ .voidbase/ generated; add it to .gitignore
21
+ main.ts voidbase composed with the project's server code
22
+ package.json
23
+ .gitignore pb_data/
24
+ pb_hooks/ routes/, middleware/, vb_hooks/, crons/ and queues/, compiled
25
+ pb_migrations/ the project's vb_migrations/, plus one file per Drizzle migration
26
+ pb_secrets/ the project's vb_secrets/: the declaration in PocketBase's shape, the values beside it
27
+ pb_public/ the client build, served at /
28
+ pb_data/ created on first run
29
+ void-entry.ts what the bundler builds into pb_hooks/void-app.js
30
+ tsconfig.json the fragment the project's tsconfig extends
31
+ shim-db.ts void/db and void/queues at runtime (see below)
32
+ shim-queues.ts
33
+ ```
34
+
35
+ `voidbase adapt` does the same pass without Vite, which is what CI and a pure-API project need.
36
+
37
+ ```bash
38
+ bun run build # or: bunx voidbase adapt
39
+ bun .voidbase/main.ts --http 127.0.0.1:8090 # the site, /api, and the panel at /_/
40
+ cd .voidbase && bunx voidbase deploy # one Cloudflare Worker with all three
41
+ ```
42
+
43
+ ## What the project owns
44
+
45
+ The project root is Void's, with three additions, all optional and all named for the voidbase thing they are:
46
+
47
+ | path | what it does |
48
+ | --- | --- |
49
+ | `vb_hooks/` | PocketBase's hooks, one per file, registered once when the app mounts |
50
+ | `vb_migrations/` | PocketBase JS migrations, copied into the generated `pb_migrations/` beside the ones generated from `db/migrations` |
51
+ | `vb_secrets/` | `main.ts` declares the app's secrets with `defineSecrets`; `secrets.json` (git-ignored) holds their values |
52
+
53
+ They sit at the project root beside Void's own `db/`, which `vb_migrations/` is the counterpart of. Everything else
54
+ is Void's, and means what Void means by it: `routes/`, `middleware/`, `crons/` and `queues/` are the server code,
55
+ `src/` is library code they import, `pages/` and `public/` are the site.
56
+
57
+ There is no hooks directory to write by hand: `routes/`, `middleware/`, `crons/` and `queues/` **are** the hooks.
58
+ The adapter compiles them into `.voidbase/pb_hooks/`, so the generated app carries its whole route surface where a
59
+ PocketBase app carries it.
60
+
61
+ ### How Void code becomes a hook
62
+
63
+ A pb_hooks file runs in a sandbox with PocketBase's globals and a `require` that reaches only its sibling hook
64
+ files. A Void route imports from npm. Three things close that gap:
65
+
66
+ 1. **One bundle.** `routes/`, `middleware/`, `vb_hooks/`, `crons/` and `queues/` are built into a single CommonJS file,
67
+ `pb_hooks/void-app.js`, with everything they import (Void's runtime, Drizzle, the app's own modules) inlined.
68
+ The `void` specifier is rewritten to `void/handler` so the Vite plugin does not come with it, and the tsconfig
69
+ aliases (`@schema`, the project's own `@/*`, and the `void/db` / `void/queues` shims) are applied by the
70
+ bundler, an alias pointing at a directory resolving to its index file.
71
+ 2. **No node builtins.** `node:async_hooks`, which Void's binding context needs, is rewritten to read
72
+ `globalThis.AsyncLocalStorage`; voidbase's hook runtime publishes it there on both runtimes. Any other builtin
73
+ fails the build with the name of the file that imported it.
74
+ 3. **No rewriting of the bundle.** Every hook file normally goes through an await-insertion pass that rewrites
75
+ calls by method name (`delete`, `next`, `send`), which would corrupt bundled code. The generated file opens with
76
+ `// voidbase:raw`, and the compiler leaves it alone.
77
+
78
+ `pb_hooks/void-app.pb.js` is the small hook beside it: the one place the hook globals are in scope. It publishes
79
+ them on `globalThis` and then requires the bundle, in that order, so `pb` already works while a module is being
80
+ imported. `main.ts` is left with nothing but the runner.
81
+
82
+ ### Writing API logic
83
+
84
+ `defineHandler` and `defineMiddleware` are how a voidbase app writes its API. A route that only needs Void's own
85
+ runtime (`void/db`, `void/storage`) needs nothing else; one that reads or writes PocketBase collections imports
86
+ PocketBase's API from the adapter, because a bundled route cannot import voidbase directly:
87
+
88
+ ```ts
89
+ // routes/api/posts/[id].ts -> GET /api/posts/:id
90
+ import { defineHandler } from "void";
91
+ import { authOf, pb, requireAuth } from "@voidbase-cloud/voidbase/adapter";
92
+
93
+ export const GET = defineHandler(requireAuth("users"), async (c) => {
94
+ const post = await pb.$app.findRecordById("posts", c.req.param("id"));
95
+ if (!post) throw new pb.NotFoundError("No such post.");
96
+ if (post.getString("owner") !== authOf(c)?.id) throw new pb.ForbiddenError();
97
+ return { post: post.publicExport() };
98
+ });
99
+ ```
100
+
101
+ | import | what it is |
102
+ | --- | --- |
103
+ | `pb.$app` | the data API: `findRecordById`, `findRecordsByFilter`, `save`, `delete`, `settings`, ... |
104
+ | `pb.Record`, `pb.$apis`, `pb.$os` | the rest of the hook surface a route is likely to want |
105
+ | `pb.BadRequestError` and friends | PocketBase's error classes, so a thrown error becomes the right HTTP response |
106
+ | `authOf(c)` | the authenticated record, exactly as a hook's `e.auth` |
107
+ | `requireAuth(...)`, `requireSuperuser()` | the Void-shaped counterparts of `$apis.requireAuth` and `requireSuperuserAuth` |
108
+
109
+ ### Hooks
110
+
111
+ PocketBase's *event* hooks (`onBootstrap`, `onRecordCreate`, the mailer hooks) fire on a record or a lifecycle
112
+ moment rather than on a URL, so they have no `routes/` equivalent. They go in `vb_hooks/`: one file, one hook,
113
+ registered once when the app mounts, in file-name order.
114
+
115
+ A file names its hook where the build can read it, without running anything:
116
+
117
+ ```ts
118
+ // vb_hooks/10.audit.ts -> onRecordAfterCreateSuccess, limited to `posts`
119
+ import { defineHook } from "@voidbase-cloud/voidbase/adapter";
120
+
121
+ export default defineHook("onRecordAfterCreateSuccess", async (e) => {
122
+ await e.next();
123
+ console.log("created", e.record?.id);
124
+ }, "posts");
125
+ ```
126
+
127
+ The trailing arguments are PocketBase's tags, the collections the hook is limited to. `routerUse`, PocketBase's
128
+ global request middleware, is a hook like any other and can be written here too, but Void already has a directory
129
+ for that, and it means the same thing:
130
+
131
+ ```ts
132
+ // middleware/20.trace.ts -> every request
133
+ import { defineMiddleware } from "void";
134
+
135
+ export default defineMiddleware(async (c, next) => {
136
+ await next();
137
+ c.res.headers.set("x-trace-id", crypto.randomUUID());
138
+ });
139
+ ```
140
+
141
+ Two build errors keep the two apart. **A `vb_hooks/` file attached to no hook fails**, by name: there is nowhere to
142
+ register it, and silently dropping it would be worse. So does a name that is not one of PocketBase's hooks, and the
143
+ error offers the nearest real ones. **A `defineHook` left in `middleware/` fails too**, and says to move it: Void's
144
+ middleware is called with `(c, next)`, which is not what a hook handler expects.
145
+
146
+ `pb` is also usable while a module is being imported, which is what lets a plain module under `src/` register
147
+ something of its own. Void's build imports the same modules again to prerender the pages, with no PocketBase in the
148
+ process, so a registration made then is held and replayed, or dropped with the process. Reading anything
149
+ (`pb.$app` and the rest) outside a request, a cron tick or a hook still throws.
150
+
151
+
152
+ ### Secrets
153
+
154
+ The secrets the app needs are named in `vb_secrets/main.ts`, where the build can read them without running anything,
155
+ and valued in `vb_secrets/secrets.json`, which stays out of git (add it to `.gitignore`; the generated app's own
156
+ `.gitignore` already lists its copy):
157
+
158
+ ```ts
159
+ // vb_secrets/main.ts
160
+ import { defineSecrets } from "@voidbase-cloud/voidbase/adapter";
161
+
162
+ export default defineSecrets({
163
+ SMTP_PASSWORD: "the mail provider's SMTP password",
164
+ CF_OAUTH_CLIENT_SECRET: "the OAuth app's client secret",
165
+ });
166
+ ```
167
+
168
+ The adapter writes them into `.voidbase/pb_secrets/` in PocketBase's shape (`main.pb.js` with `secrets({...})`,
169
+ `secrets.json` copied beside it), which is what `voidbase serve` and `voidbase deploy` read: locally the values
170
+ enter the process environment, on Cloudflare they become the Worker's secrets (a deploy stores what the Worker
171
+ lacks, `voidbase secrets push` replaces), and a deploy refuses to go ahead while a declared secret has no value
172
+ anywhere. The app reads them like any other binding: `c.env.SMTP_PASSWORD` in
173
+ a route, `pb.$os.getenv("SMTP_PASSWORD")` in a hook. A value in `secrets.json` that `main.ts` does not declare
174
+ fails the build, by name: it would silently never reach the Worker. Details and the `voidbase secrets` commands:
175
+ `docs/deploy.md`.
176
+
177
+ ## What maps to what
178
+
179
+ | Void | voidbase | notes |
180
+ | --- | --- | --- |
181
+ | `index.html`, `public/`, the client build | `.voidbase/pb_public/` | served at `/`; `404.html` is copied from `index.html` so a deep link behaves the same on Bun and on the asset layer |
182
+ | `routes/**/*.ts` | `.voidbase/pb_hooks/void-app.js` | `[id]` → `:id`, `[...rest]` → catch-all, `(group)/` stripped, `_file.ts` ignored, `.dev.ts` / `.prod.ts` honoured |
183
+ | `middleware/*.ts` | `routerUse(...)`, in file order | every request, as in Void (see below) |
184
+ | `vb_hooks/*.ts` | the hook each file names, registered once | `onBootstrap`, `onRecordCreate`, the mailer hooks |
185
+ | `vb_secrets/main.ts` + `secrets.json` | `.voidbase/pb_secrets/main.pb.js` + `secrets.json` | `voidbase deploy` stores the values as the Worker's secrets; `bun .voidbase/main.ts` loads them (see below) |
186
+ | `crons/*.ts` | `cronAdd(<file name>, cron, handler)` | listed by `GET /api/crons`, runnable with `POST /api/crons/<name>` |
187
+ | `queues/*.ts` | a voidbase job per message | `void/queues` and `c.env.QUEUE_<NAME>` produce; the consumer runs on the jobs queue, or inline where there is none |
188
+ | `db/schema.ts` + `void/db` | Drizzle over voidbase's D1 | the same database PocketBase's collections live in |
189
+ | `db/migrations/*.sql` | `.voidbase/pb_migrations/<name>.void.js` | applied and recorded like any other migration, on both runtimes |
190
+
191
+ Point the project's `tsconfig.json` at the adapter's fragment so the editor and Bun agree:
192
+
193
+ ```jsonc
194
+ { "extends": "./.voidbase/tsconfig.json" } // instead of "./.void/tsconfig.json"
195
+ ```
196
+
197
+ Void's own fragment maps `void/db` and `void/queues` to declaration files. That is right for `tsc` and wrong for
198
+ Bun, which honours `paths` at runtime and would resolve those imports to a `.d.ts` that exports nothing. The
199
+ adapter's fragment repoints those two at shims that take the values from Void's published runtime and the types
200
+ from Void's declarations, and passes every other mapping (`@schema`, `void/routes`) through unchanged.
201
+
202
+ ## Two shapes of app
203
+
204
+ The adapter looks at what the project actually has:
205
+
206
+ - **Static.** No `routes/`, `middleware/`, `vb_hooks/`, `crons/` or `queues/` (`vb_secrets/` alone changes nothing). The generated app is `main.ts`
207
+ plus `pb_public`.
208
+ - **Server.** Anything else. The bundle and its hook go into `pb_hooks/`. `voidbase deploy`, run from `.voidbase/`,
209
+ ships that directory whole, so the same code serves on Cloudflare.
210
+
211
+ Pages are prerendered when `void.json` sets `"output": "static"`; they land in `pb_public` as plain HTML and need no
212
+ runtime. Pages that still render per request have nowhere to run here, and the build says so.
213
+
214
+ ## How the server code runs
215
+
216
+ Void's `defineHandler` returns a plain Hono handler, and voidbase's app is Hono, so handlers run unchanged. Two
217
+ details make that true rather than nearly true:
218
+
219
+ - Routes register through `routerAdd`, the registry every pb_hooks route uses. The RequestEvent it hands the
220
+ handler carries `.c`, the real Hono context, and the adapter overlays the route's own parameters on
221
+ `c.req.param()`, since the hook router matched a pattern rather than Hono's own.
222
+ - Every handler, cron and queue consumer runs inside `withRuntimeEnv`, Void's binding context. That is why
223
+ `void/db`, `void/storage`, `void/env` and `void/queues` resolve against voidbase's D1 and R2 with no shim of
224
+ their own, and why `c.env` carries the app's queue producers.
225
+
226
+ Return values convert exactly as Void converts them (object → JSON, string → HTML, `null` → 204, `Response` as-is),
227
+ because the adapter calls Void's own `convertReturnValue`.
228
+
229
+ ## Things to know
230
+
231
+ **voidbase's API wins.** `/api/collections`, `/api/files`, `/api/realtime`, `/api/settings`, `/api/logs`,
232
+ `/api/backups`, `/api/crons`, `/api/batch`, `/api/health`, `/api/webauthn` (voidbase's passkey endpoints) and `/_/`
233
+ are voidbase's own. An app route on one of those paths never runs; the build warns and names it. Everything else
234
+ under `/api` is yours.
235
+
236
+ **Middleware runs on every request.** `middleware/` registers through `routerUse`, PocketBase's own global
237
+ middleware, so it means what Void means by it: every request, in file order, before whatever answers. That includes
238
+ PocketBase's endpoints and the admin panel, so a middleware that throws takes the whole backend with it. Guard on
239
+ the path when a middleware is only meant for the app's own routes.
240
+
241
+ **Queues are one queue.** Cloudflare Queues are provisioned per instance, so every app queue rides voidbase's own
242
+ jobs queue as a `{ type: "queue", queue, body }` message and is fanned back out to the right consumer. Without a
243
+ queue binding (the Bun runtime, or a deploy whose token could not create one) `send` runs the consumer inline. A
244
+ consumer that throws, or calls `retry()`, is retried by voidbase's job runner.
245
+
246
+ **Not carried over.** The build warns and keeps going:
247
+
248
+ | what | why |
249
+ | --- | --- |
250
+ | `pages/` that render per request | prerender them with `"output": "static"` and they land in `pb_public`; there is no page renderer at runtime |
251
+ | `routes/**/*.ws.ts` | document WebSockets are Durable Objects, and voidbase's realtime hub owns that binding |
252
+ | `void/kv` | voidbase binds D1 and R2 only; a collection is the place for that data |
253
+ | `void/isr` | ISR runs in the Void platform's dispatch worker, which a self-hosted app does not have |
254
+
255
+ **Build with Bun.** voidbase ships TypeScript sources, and Vite loads its config through the runtime, so use
256
+ `bunx --bun vite build` (or `bun run build` with Bun as the package manager). `voidbase adapt` has no such
257
+ constraint.
258
+
259
+ **Nothing is written outside `.voidbase/`.** Delete the directory and the next build makes it again, so it belongs
260
+ in `.gitignore` and never in review.
261
+
262
+ ## Testing it
263
+
264
+ `test/adapter.ts` converts `test/fixtures/void-app`, boots it with the generated `main.ts` and exercises the whole
265
+ surface: the generated layout, route paths and parameters, literal-beats-parameter ordering, middleware order and
266
+ its reach over PocketBase's own endpoints, `void/db` through the shim, `void/storage`, a queue round trip, a cron
267
+ run, both kinds of migration, an `onBootstrap` hook running exactly once, a tagged event hook, a tsconfig alias
268
+ resolving to a directory's index file, the four build errors (a hook attached to nothing, a misspelt hook name, a
269
+ hook left in `middleware/`, a secret value nothing declares), `vb_secrets/` reaching the app through `$os.getenv`,
270
+ and that nothing is written outside `.voidbase/`. Run it with
271
+ `bun test/adapter.ts`, or as part of `bash scripts/ci.sh` (step `adapter`).