@voidbase-cloud/voidbase 0.2.1 → 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 +59 -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 +6 -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
@@ -7,6 +7,20 @@ const ROOT = resolve(import.meta.dir, "../..");
7
7
 
8
8
  // mode "package": a visible project importing the voidbase package (voidbase cloud init).
9
9
  // mode "internal": a project inside this package at .cloud/<slug>, importing ../../src etc. (voidbase deploy).
10
+ export interface RedirectEntry { source: string; host?: string; path: string; to: string; status: number; line: number }
11
+ // Netlify/Pages-style `_redirects`: `source destination [status]`, `#` comments. A source may carry a host
12
+ // (`https://api.example.com/`), which scopes the rule to that hostname. 3xx rules only (default 302).
13
+ export function parseRedirects(text: string): RedirectEntry[] {
14
+ const out: RedirectEntry[] = [];
15
+ text.split("\n").forEach((raw, i) => {
16
+ const line = raw.replace(/#.*$/, "").trim(); if (!line) return;
17
+ const [source, to, statusRaw] = line.split(/\s+/); if (!source || !to) return;
18
+ const status = Number((statusRaw ?? "302").replace(/!$/, "")); if (![301, 302, 303, 307, 308].includes(status)) return;
19
+ const m = source.match(/^https?:\/\/([^/]+)(\/.*)?$/);
20
+ out.push({ source, host: m ? m[1]!.toLowerCase() : undefined, path: m ? m[2] || "/" : source, to, status, line: i + 1 });
21
+ });
22
+ return out;
23
+ }
10
24
  export function writeCloudProject(out: string, mode: "package" | "internal" = "package", extra: { hooksDir?: string; migrationsDir?: string; entry?: string; queue?: string | false; hub?: boolean } = {}): { files: number; out: string } {
11
25
  const parentPkg = existsSync("package.json") ? (JSON.parse(readFileSync("package.json", "utf8")) as { dependencies?: Record<string, string> }) : {};
12
26
  const spec = parentPkg.dependencies?.["@voidbase-cloud/voidbase"] ?? parentPkg.dependencies?.voidbase ?? "^0.1.0";
@@ -5,9 +5,10 @@
5
5
  // which builds, applies the D1 migrations and uploads the Worker.
6
6
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
7
  import { resolve } from "node:path";
8
- import { writeCloudProject } from "./cloud-init";
8
+ import { parseRedirects, writeCloudProject, type RedirectEntry } from "./cloud-init";
9
9
  import { loadEnv } from "./serve";
10
- import { CfApi, attachCustomDomain, ensureD1, ensureQueue, ensureR2, rateLimitNamespace, resolveAccount, workersSubdomain } from "../cloud/rest";
10
+ import { loadSecrets, SECRETS_DIR, workerSecretNames } from "./secrets";
11
+ import { CfApi, attachCustomDomain, ensureD1, ensureQueue, ensureR2, findZone, rateLimitNamespace, resolveAccount, workersSubdomain } from "../cloud/rest";
11
12
 
12
13
  const API = (process.env.CLOUDFLARE_API_BASE ?? "https://api.cloudflare.com/client/v4").replace(/\/$/, "");
13
14
  export const TOKEN_ENV = "VOIDBASE_DEPLOY_CF_API_KEY";
@@ -73,14 +74,27 @@ export function loadEnvFiles(files = [".env", ".env.local", "../.env", "../.env.
73
74
  return loaded;
74
75
  }
75
76
 
76
- export async function deployToCloudflare(opts: DeployOptions = {}): Promise<{ name: string; account: string; url: string | null; wranglerConfig: string; project: string }> {
77
+ /** The environment, the token, the account and the worker name a deploy (or `voidbase secrets`) targets. */
78
+ export async function deployTarget(opts: Pick<DeployOptions, "name" | "account" | "log"> = {}): Promise<{ api: CfApi; token: string; account: { id: string; name: string }; name: string; secretsDir: string; secrets: ReturnType<typeof loadSecrets> }> {
77
79
  const log = opts.log ?? ((l: string) => console.log(l));
80
+ // pb_secrets/ first: the declared names, and on a dev machine their values, which count as environment from here
81
+ // on. The shell outranks secrets.json, and secrets.json outranks the .env files, so a dev placeholder in .env
82
+ // (VOIDBASE_SUPERUSER_PASSWORD=changeme123) never shadows the real value kept beside the declaration.
83
+ const secretsDir = resolve(process.env.VOIDBASE_SECRETS_DIR || SECRETS_DIR);
84
+ const secrets = loadSecrets(secretsDir);
78
85
  loadEnv(); const fromFiles = loadEnvFiles(); if (fromFiles.length) log(`from .env: ${fromFiles.join(", ")}`);
86
+ if (secrets.state.declaration) log(`${secretsDir}: ${secrets.state.declaration.names.length} secret(s) declared, ${secrets.state.provided.length} valued here${secrets.undeclared.length ? `; in secrets.json but not declared (not deployed): ${secrets.undeclared.join(", ")}` : ""}`);
79
87
  const token = process.env[TOKEN_ENV] || process.env.CLOUDFLARE_API_TOKEN || ""; // empty means unset
80
88
  if (!token) { log(`${TOKEN_ENV} is not set.\n\n${tokenHelp()}`); throw new Error(`${TOKEN_ENV} missing`); }
81
89
  const name = slug(opts.name || process.env.VOIDBASE_DEPLOY_NAME || projectName());
82
90
  const api = new CfApi(token, API);
83
91
  const account = await resolveAccount(api, opts.account || process.env.VOIDBASE_DEPLOY_CF_ACCOUNT_ID || undefined).catch((e: Error) => { throw new Error(`${e.message} (is it ${TOKEN_ENV} with Account Settings read?)`); });
92
+ return { api, token, account, name, secretsDir, secrets };
93
+ }
94
+
95
+ export async function deployToCloudflare(opts: DeployOptions = {}): Promise<{ name: string; account: string; url: string | null; wranglerConfig: string; project: string }> {
96
+ const log = opts.log ?? ((l: string) => console.log(l));
97
+ const { api, token, account, name, secretsDir, secrets: pbSecrets } = await deployTarget(opts);
84
98
  log(`account ${account.name} (${account.id}), worker "${name}"`);
85
99
  const db = await ensureD1(api, account.id, `${name}-db`); log(`D1 ${name}-db ${db.created ? "created" : "exists"} (${db.uuid})`);
86
100
  const bucket = await ensureR2(api, account.id, `${name}-storage`); log(`R2 ${name}-storage ${bucket.created ? "created" : "exists"}`);
@@ -93,8 +107,19 @@ export async function deployToCloudflare(opts: DeployOptions = {}): Promise<{ na
93
107
  // Workers Free allows 5 cron triggers per account; without the trigger PocketBase's maintenance runs lazily in requests
94
108
  const cron = opts.cron ?? !off(process.env.VOIDBASE_DEPLOY_CRON);
95
109
  // a custom domain on a zone of the account (wrangler attaches it: DNS record + certificate); workers.dev is then off
96
- const domain = String(opts.domain || process.env.VOIDBASE_DEPLOY_DOMAIN || "").trim().replace(/^https?:\/\//, "").replace(/\/.*$/, "").toLowerCase();
97
- if (domain && !/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) throw new Error(`invalid custom domain "${domain}"`);
110
+ // several hostnames may be listed (comma separated); the first is the Worker's URL, all are attached
111
+ const domains = String(opts.domain || process.env.VOIDBASE_DEPLOY_DOMAIN || "").split(",").map((d) => d.trim().replace(/^https?:\/\//, "").replace(/\/.*$/, "").toLowerCase()).filter(Boolean);
112
+ for (const d of domains) if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(d)) throw new Error(`invalid custom domain "${d}"`);
113
+ const domain = domains[0] ?? "";
114
+ // the static site next to the API: --public-dir, VOIDBASE_DEPLOY_PUBLIC_DIR, or ./pb_public when it exists (PocketBase's default)
115
+ const publicDir = opts.publicDir || process.env.VOIDBASE_DEPLOY_PUBLIC_DIR || (existsSync(resolve("pb_public")) ? "pb_public" : undefined);
116
+ if (publicDir && !existsSync(resolve(publicDir, "index.html"))) throw new Error(`public dir ${resolve(publicDir)} has no index.html (build the site first)`);
117
+ // <public dir>/_redirects, Netlify/Pages syntax. Path-only lines go to the assets as Cloudflare's own _redirects (it
118
+ // only accepts relative sources); host-scoped lines (`https://api.example.com/ /_/ 302`) become zone Redirect Rules
119
+ // after the upload, which is how one Worker behind several custom domains answers differently per hostname.
120
+ const redirects = publicDir && existsSync(resolve(publicDir, "_redirects")) ? parseRedirects(readFileSync(resolve(publicDir, "_redirects"), "utf8")) : [];
121
+ const hostRedirects = redirects.filter((r) => r.host), pathRedirects = redirects.filter((r) => !r.host);
122
+ if (redirects.length) log(`redirects (${resolve(publicDir!, "_redirects")}): ${redirects.map((r) => `${r.source} -> ${r.to} (${r.status})`).join(", ")}${hostRedirects.length ? `; the ${hostRedirects.length} host-scoped rule(s) become zone Redirect Rules after the upload` : ""}`);
98
123
  let queue: string | false = false;
99
124
  if (wantQueue) {
100
125
  const q = await ensureQueue(api, account.id, `${name}-jobs`);
@@ -143,14 +168,43 @@ export async function deployToCloudflare(opts: DeployOptions = {}): Promise<{ na
143
168
  const saved = existsSync(credFile) ? (JSON.parse(readFileSync(credFile, "utf8")) as { email: string; password: string }) : null;
144
169
  const placeholder = !password || password === "changeme123"; // the local dev default never goes live
145
170
  if (placeholder && saved && (!email || email === saved.email)) { email = saved.email; password = saved.password; }
146
- if (!email) email = "admin@example.com";
147
- if (!password || password === "changeme123") { password = randomPassword(); log(`generated a superuser password for ${email} (saved in ${credFile}; change it after the first login)`); }
148
- writeFileSync(credFile, JSON.stringify({ email, password }, null, 2) + "\n", { mode: 0o600 });
171
+ // what the Worker already holds: a checkout with no credentials of its own (CI) must not replace the superuser
172
+ // the Worker has with a generated one that only this checkout would know
173
+ const onWorker = await workerSecretNames(api, account.id, name);
174
+ const keepSuperuser = placeholder && !saved && onWorker.includes("VOIDBASE_SUPERUSER_EMAIL") && onWorker.includes("VOIDBASE_SUPERUSER_PASSWORD");
175
+ if (keepSuperuser) log("superuser: no credentials in this checkout, the Worker keeps the ones it has");
176
+ else {
177
+ if (!email) email = "admin@example.com";
178
+ if (!password || password === "changeme123") { password = randomPassword(); log(`generated a superuser password for ${email} (saved in ${credFile}; change it after the first login)`); }
179
+ writeFileSync(credFile, JSON.stringify({ email, password }, null, 2) + "\n", { mode: 0o600 });
180
+ }
181
+
182
+ // the Worker's secrets: the superuser, VOIDBASE_DEPLOY_SECRETS=X,Y from the environment, and the declared
183
+ // pb_secrets/ names the Worker does not have yet. A deploy ships code; a value the Worker already holds is
184
+ // replaced only by `voidbase secrets push`, so a checkout whose secrets.json carries dev values (another OAuth
185
+ // client, the placeholder password) cannot overwrite production by deploying. A declared name with no value
186
+ // here must already be on the Worker.
187
+ const declared = pbSecrets.state.declaration?.names ?? [];
188
+ const secretMap = new Map<string, string>(keepSuperuser ? [] : [["VOIDBASE_SUPERUSER_EMAIL", email], ["VOIDBASE_SUPERUSER_PASSWORD", password]]);
189
+ for (const k of extraSecrets) if (process.env[k]) secretMap.set(k, process.env[k]!);
190
+ const kept: string[] = [];
191
+ for (const k of declared) {
192
+ const v = pbSecrets.state.values?.[k]; if (v === undefined) continue;
193
+ if (onWorker.includes(k)) { kept.push(k); continue; }
194
+ if (k === "VOIDBASE_SUPERUSER_PASSWORD" && v === "changeme123") { log("secrets: VOIDBASE_SUPERUSER_PASSWORD in secrets.json is the dev placeholder, not stored"); continue; }
195
+ secretMap.set(k, v);
196
+ }
197
+ const missingSecrets = declared.filter((k) => !secretMap.has(k) && !onWorker.includes(k));
198
+ if (missingSecrets.length) {
199
+ const msg = `${missingSecrets.length} declared secret(s) have no value in ${secretsDir}/secrets.json and are not on the Worker "${name}" yet: ${missingSecrets.join(", ")}. Push them once from a machine that has them: voidbase secrets push --name ${name}`;
200
+ if (opts.dryRun) log(`secrets: ${msg}`); else throw new Error(msg);
201
+ } else if (declared.length) log(`secrets: ${declared.length} declared; ${declared.filter((k) => secretMap.has(k)).length} stored from here, ${kept.length} kept as the Worker has them (voidbase secrets push replaces)`);
202
+ const secrets = [...secretMap.entries()];
149
203
 
150
204
  const url = domain ? `https://${domain}` : await workersSubdomain(api, account.id).then((s) => (s ? `https://${name}.${s}.workers.dev` : null));
151
- if (domain) log(`custom domain ${domain} (workers.dev off): attached through the Workers Custom Domains API after the upload (Cloudflare adds the DNS record and certificate)`);
205
+ if (domain) log(`custom domain${domains.length > 1 ? "s" : ""} ${domains.join(", ")} (workers.dev off): attached through the Workers Custom Domains API after the upload (Cloudflare adds the DNS record and certificate)`);
152
206
  log(`bindings: D1, R2${hub ? ", realtime hub (Durable Object)" : ""}${queue ? ", Queue" : ""}${rateLimit ? `, rate limit ceiling ${rateLimit.limit}/${rateLimit.period}s per IP` : ""}${analytics ? ", Analytics Engine (needs Analytics Engine enabled once for the account: https://dash.cloudflare.com/" + account.id + "/workers/analytics-engine)" : ""}`);
153
- if (opts.dryRun) { log(`dry run: would sync the panel${opts.publicDir ? ` and ${opts.publicDir}` : ""} into ${cloud}/public, put 2 secrets and run void deploy --backend cloudflare (${url ?? "url unknown"})`); return { name, account: account.id, url, wranglerConfig, project: cloud }; }
207
+ if (opts.dryRun) { log(`dry run: would sync the panel${publicDir ? ` and ${publicDir}` : ""} into ${cloud}/public, put ${secrets.length} secrets (${secrets.map(([k]) => k).join(", ")}) and run void deploy --backend cloudflare (${url ?? "url unknown"})`); return { name, account: account.id, url, wranglerConfig, project: cloud }; }
154
208
 
155
209
  // the toolchain comes with the voidbase package (void, and wrangler through void)
156
210
  const voidDir = resolve(Bun.resolveSync("void/package.json", PKG), "..");
@@ -158,22 +212,65 @@ export async function deployToCloudflare(opts: DeployOptions = {}): Promise<{ na
158
212
  // values also exported in the shell are stripped from baked vars by the Cloudflare backend, so keep the vars file clean instead
159
213
  // the generated project has no node_modules of its own: `void deploy` shells out to `vite build`, so the package's toolchain goes on PATH
160
214
  const binDirs = [resolve(PKG, "node_modules/.bin"), resolve(voidDir, "..", ".bin")].filter((d, i, a) => a.indexOf(d) === i);
161
- const env: Record<string, string | undefined> = { ...process.env, PATH: `${binDirs.join(":")}:${process.env.PATH ?? ""}`, CLOUDFLARE_API_TOKEN: token, CLOUDFLARE_ACCOUNT_ID: account.id, VOIDBASE_SUPERUSER_EMAIL: email, VOIDBASE_SUPERUSER_PASSWORD: password };
215
+ const env: Record<string, string | undefined> = { ...process.env, PATH: `${binDirs.join(":")}:${process.env.PATH ?? ""}`, CLOUDFLARE_API_TOKEN: token, CLOUDFLARE_ACCOUNT_ID: account.id, ...(keepSuperuser ? {} : { VOIDBASE_SUPERUSER_EMAIL: email, VOIDBASE_SUPERUSER_PASSWORD: password }) };
162
216
  for (const k of Object.keys(baked)) delete env[k];
163
217
  const sh = async (cmd: string[], input?: string) => { const p = Bun.spawn(cmd, { cwd: cloud, env: env as Record<string, string>, stdin: input === undefined ? "inherit" : new TextEncoder().encode(input), stdout: "inherit", stderr: "inherit" }); const code = await p.exited; if (code !== 0) throw new Error(`${cmd.join(" ")} exited with ${code}`); };
164
218
  mkdirSync(`${cloud}/public`, { recursive: true });
165
219
  await sh(["bun", resolve(PKG, "scripts/sync-panel.ts"), "--dest", `${cloud}/public/_`]);
166
- if (opts.publicDir) await sh(["bun", resolve(PKG, "scripts/sync-app.ts"), "--dest", `${cloud}/public`], undefined).catch((e) => log(`frontend build not synced: ${e instanceof Error ? e.message : e}`));
167
- const secrets: [string, string][] = [["VOIDBASE_SUPERUSER_EMAIL", email], ["VOIDBASE_SUPERUSER_PASSWORD", password], ...extraSecrets.filter((k) => process.env[k]).map((k): [string, string] => [k, process.env[k]!])];
220
+ if (publicDir) {
221
+ env.VOIDBASE_APP_DIR = resolve(publicDir); await sh(["bun", resolve(PKG, "scripts/sync-app.ts"), "--dest", `${cloud}/public`], undefined); log(`static site ${resolve(publicDir)} served at / (the panel stays at /_/)`);
222
+ if (pathRedirects.length) writeFileSync(`${cloud}/public/_redirects`, pathRedirects.map((r) => `${r.path} ${r.to} ${r.status}`).join("\n") + "\n");
223
+ }
224
+ if (secrets.length) log(`secrets: storing ${secrets.map(([k]) => k).join(", ")} on the Worker`);
168
225
  for (const [k, v] of secrets) await sh(["bun", wrangler, "secret", "put", k, "--name", name], v + "\n");
169
226
  await sh([voidBin, "deploy", "--backend", "cloudflare"]);
170
- if (domain) {
171
- const d = await attachCustomDomain(api, account.id, { hostname: domain, service: name });
227
+ for (const host of domains) {
228
+ const d = await attachCustomDomain(api, account.id, { hostname: host, service: name });
172
229
  log(`custom domain ${d.hostname} ${d.created ? "attached" : "already attached"} (zone ${d.zone_id}); the certificate can take a minute`);
173
230
  }
231
+ if (hostRedirects.length) await applyZoneRedirects(api, account.id, name, hostRedirects, log);
174
232
  if (url) {
175
233
  const ok = await fetch(`${url}/api/health`).then((r) => r.status).catch(() => 0);
176
- log(`\nlive: ${url} (health ${ok || "not reachable yet"})\n├─ REST API: ${url}/api/\n└─ Dashboard: ${url}/_/ sign in as ${email} (password in ${credFile})`);
234
+ log(`\nlive: ${url} (health ${ok || "not reachable yet"})\n├─ REST API: ${url}/api/\n└─ Dashboard: ${url}/_/ sign in as ${keepSuperuser ? "the superuser the Worker already had" : `${email} (password in ${credFile})`}`);
177
235
  } else log("deployed; workers.dev subdomain not enabled on this account, add a route or enable it in the dashboard (or VOIDBASE_DEPLOY_DOMAIN=<host> / --domain)");
178
236
  return { name, account: account.id, url, wranglerConfig, project: cloud };
179
237
  }
238
+
239
+ // ---- host-scoped redirects as zone Redirect Rules (Rulesets API, phase http_request_dynamic_redirect) --------------
240
+ // One rule per `_redirects` line, tagged `voidbase:<worker>:` in its description so a redeploy replaces exactly its own
241
+ // rules and leaves the zone's other redirect rules alone. Needs the zone permission Single Redirect (edit) on the deploy
242
+ // token ("Dynamic URL Redirects Write" in the API's permission listing, next to the DNS permission the token may already
243
+ // carry for the zone). Without it the deploy logs the rules to create by hand and carries on.
244
+ const quote = (v: string) => JSON.stringify(v);
245
+ export function redirectRule(worker: string, r: RedirectEntry): Record<string, unknown> {
246
+ const wildcard = r.path.endsWith("/*"); const prefix = wildcard ? r.path.slice(0, -1) : r.path; // "/docs/*" -> "/docs/"
247
+ const expression = wildcard ? (prefix === "/" ? `(http.host eq ${quote(r.host!)})` : `(http.host eq ${quote(r.host!)} and starts_with(http.request.uri.path, ${quote(prefix)}))`) : `(http.host eq ${quote(r.host!)} and http.request.uri.path eq ${quote(r.path)})`;
248
+ const absolute = (to: string) => (/^https?:\/\//.test(to) ? to : `https://${r.host}${to.startsWith("/") ? "" : "/"}${to}`);
249
+ const splat = r.to.includes(":splat");
250
+ const target_url = splat
251
+ ? { expression: `concat(${quote(absolute(r.to).replace(":splat", "").replace(/\/$/, ""))}, ${prefix === "/" ? "http.request.uri.path" : `substring(http.request.uri.path, ${prefix.length - 1})`})` }
252
+ : { value: absolute(r.to) };
253
+ return { description: `voidbase:${worker}:${r.source}`, expression, action: "redirect", action_parameters: { from_value: { status_code: r.status, target_url, preserve_query_string: true } }, enabled: true };
254
+ }
255
+ export async function applyZoneRedirects(api: CfApi, account: string, worker: string, entries: RedirectEntry[], log: (l: string) => void): Promise<void> {
256
+ const byZone = new Map<string, { zone: { id: string; name: string }; rules: Record<string, unknown>[] }>();
257
+ for (const r of entries) {
258
+ const zone = await findZone(api, r.host!, account);
259
+ if (!zone) { log(`redirect ${r.source}: no zone on the account covers ${r.host}, rule skipped`); continue; }
260
+ const slot = byZone.get(zone.id) ?? { zone, rules: [] }; slot.rules.push(redirectRule(worker, r)); byZone.set(zone.id, slot);
261
+ }
262
+ for (const { zone, rules } of byZone.values()) {
263
+ const path = `/zones/${zone.id}/rulesets/phases/http_request_dynamic_redirect/entrypoint`;
264
+ try {
265
+ const cur = await api.raw("GET", path); const body = await cur.text();
266
+ if (cur.status === 403 || cur.status === 401) throw new Error("permission");
267
+ const existing = cur.ok ? ((JSON.parse(body) as { result?: { rules?: Record<string, unknown>[] } }).result?.rules ?? []) : [];
268
+ const kept = existing.filter((x) => !String(x.description ?? "").startsWith(`voidbase:${worker}:`));
269
+ await api.json("PUT", path, { rules: [...kept, ...rules] });
270
+ log(`zone ${zone.name}: ${rules.length} redirect rule(s) set (${rules.map((x) => String(x.description).split(":").slice(2).join(":")).join(", ")})`);
271
+ } catch (e) {
272
+ const why = e instanceof Error && e.message === "permission" ? "the token lacks the zone permission Single Redirect > Edit (API name: Dynamic URL Redirects Write) for the zone" : e instanceof Error ? e.message : String(e);
273
+ log(`zone ${zone.name}: redirect rules not set (${why}). Add that permission to the token and deploy again, or create them under Rules > Redirect Rules:\n${rules.map((x) => ` ${x.expression} -> ${JSON.stringify((x.action_parameters as { from_value: { target_url: unknown } }).from_value.target_url)}`).join("\n")}`);
274
+ }
275
+ }
276
+ }
@@ -0,0 +1,169 @@
1
+ // pb_secrets/: the app's secrets, declared where the tooling can read them and valued where git cannot see them.
2
+ //
3
+ // pb_secrets/main.pb.js the declaration, committed: `secrets({ NAME: "what it is", ... })`. Read, never run.
4
+ // pb_secrets/secrets.json the values, git-ignored: `{ "NAME": "value", ... }`. On a dev machine only.
5
+ //
6
+ // `voidbase serve` loads the values into the process environment, so `$os.getenv("NAME")` and the app's own code see
7
+ // them exactly as they will on Cloudflare. `voidbase deploy` stores the values as the Worker's secrets (encrypted,
8
+ // per Worker, so two instances on one account never share one) and refuses to deploy while a declared secret has
9
+ // neither a local value nor one already on the Worker: a CI checkout has no secrets.json, and that is the point --
10
+ // the values are pushed once from a machine that has them (`voidbase secrets push`) and the pipeline needs nothing
11
+ // but the deploy token. Cloudflare's account-level Secrets Store is deliberately not used: one store is shared by
12
+ // every Worker of the account, and its bindings are read asynchronously, which `$os.getenv` is not.
13
+ import { existsSync, readFileSync } from "node:fs";
14
+ import { join, resolve } from "node:path";
15
+ import ts from "typescript";
16
+ import type { CfApi } from "../cloud/rest";
17
+
18
+ export const SECRETS_DIR = "pb_secrets";
19
+ export const DECLARATION_FILE = "main.pb.js";
20
+ export const VALUES_FILE = "secrets.json";
21
+ const NAME = /^[A-Z][A-Z0-9_]*$/;
22
+
23
+ export interface SecretsDeclaration {
24
+ /** the file the names came from */
25
+ file: string;
26
+ /** declared names, in file order */
27
+ names: string[];
28
+ /** what each one is, when the declaration says */
29
+ descriptions: Record<string, string>;
30
+ }
31
+
32
+ /**
33
+ * The names a declaration file names, without evaluating it: `secrets({ A: "what A is", B: "" })`, or
34
+ * `secrets(["A", "B"])`. The same reader serves the adapter's `defineSecrets({...})` in vb_secrets/main.ts.
35
+ */
36
+ export function parseSecretsDeclaration(code: string, file = DECLARATION_FILE, callee: string | string[] = ["secrets", "defineSecrets"]): SecretsDeclaration {
37
+ const callees = new Set(Array.isArray(callee) ? callee : [callee]);
38
+ const sf = ts.createSourceFile(file, code, ts.ScriptTarget.Latest, true);
39
+ const names: string[] = []; const descriptions: Record<string, string> = {};
40
+ const text = (n: ts.Node | undefined) => (n && (ts.isStringLiteralLike(n) || ts.isIdentifier(n)) ? n.text : null);
41
+ const add = (name: string | null, description: string | null) => {
42
+ if (!name) return;
43
+ if (!NAME.test(name)) throw new Error(`voidbase: ${file}: "${name}" is not a secret name (UPPER_CASE, letters, digits and underscores, like an environment variable)`);
44
+ if (!names.includes(name)) names.push(name);
45
+ if (description) descriptions[name] = description;
46
+ };
47
+ const visit = (node: ts.Node) => {
48
+ if (ts.isCallExpression(node)) {
49
+ const fn = ts.isIdentifier(node.expression) ? node.expression.text : ts.isPropertyAccessExpression(node.expression) ? node.expression.name.text : "";
50
+ const arg = node.arguments[0];
51
+ if (callees.has(fn) && arg) {
52
+ if (ts.isObjectLiteralExpression(arg)) {
53
+ for (const p of arg.properties) {
54
+ if (ts.isPropertyAssignment(p)) {
55
+ const init = p.initializer;
56
+ const description = ts.isStringLiteralLike(init) ? init.text
57
+ : ts.isObjectLiteralExpression(init) ? text(init.properties.find((q): q is ts.PropertyAssignment => ts.isPropertyAssignment(q) && text(q.name) === "description")?.initializer) : null;
58
+ add(text(p.name), description);
59
+ } else if (ts.isShorthandPropertyAssignment(p)) add(p.name.text, null);
60
+ }
61
+ } else if (ts.isArrayLiteralExpression(arg)) for (const el of arg.elements) add(text(el), null);
62
+ else throw new Error(`voidbase: ${file}: ${fn}() takes an object of names ({ NAME: "what it is" }) or an array of names`);
63
+ }
64
+ }
65
+ ts.forEachChild(node, visit);
66
+ };
67
+ visit(sf);
68
+ return { file, names, descriptions };
69
+ }
70
+
71
+ /** The declaration of a pb_secrets/ directory, or null when there is none. */
72
+ export function readSecretsDeclaration(dir = SECRETS_DIR): SecretsDeclaration | null {
73
+ const file = join(resolve(dir), DECLARATION_FILE);
74
+ if (!existsSync(file)) return null;
75
+ return parseSecretsDeclaration(readFileSync(file, "utf8"), file);
76
+ }
77
+
78
+ /** The values of a pb_secrets/ directory (`secrets.json`), or null when the file is absent. Every value is a string. */
79
+ export function readSecretsValues(dir = SECRETS_DIR): Record<string, string> | null {
80
+ const file = join(resolve(dir), VALUES_FILE);
81
+ if (!existsSync(file)) return null;
82
+ let parsed: unknown;
83
+ try { parsed = JSON.parse(readFileSync(file, "utf8")); } catch (e) { throw new Error(`voidbase: ${file} is not JSON: ${e instanceof Error ? e.message : String(e)}`); }
84
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error(`voidbase: ${file} must be an object: { "NAME": "value" }`);
85
+ const out: Record<string, string> = {};
86
+ for (const [k, v] of Object.entries(parsed as Record<string, unknown>)) {
87
+ if (!NAME.test(k)) throw new Error(`voidbase: ${file}: "${k}" is not a secret name (UPPER_CASE, letters, digits and underscores)`);
88
+ if (v === null || v === undefined) continue;
89
+ out[k] = typeof v === "string" ? v : typeof v === "object" ? JSON.stringify(v) : String(v);
90
+ }
91
+ return out;
92
+ }
93
+
94
+ export interface SecretsState {
95
+ dir: string;
96
+ declaration: SecretsDeclaration | null;
97
+ values: Record<string, string> | null;
98
+ /** declared names with a local value */
99
+ provided: string[];
100
+ /** declared names without a local value */
101
+ unprovided: string[];
102
+ /** local values that no declaration names */
103
+ undeclared: string[];
104
+ }
105
+
106
+ /** What a pb_secrets/ directory declares and holds, and how the two compare. */
107
+ export function secretsState(dir = SECRETS_DIR): SecretsState {
108
+ const declaration = readSecretsDeclaration(dir);
109
+ const values = readSecretsValues(dir);
110
+ if (!declaration && values && Object.keys(values).length) {
111
+ throw new Error(`voidbase: ${join(resolve(dir), VALUES_FILE)} holds ${Object.keys(values).length} secret(s) but nothing declares them. Name them in ${join(dir, DECLARATION_FILE)}:\n secrets({ ${Object.keys(values).map((k) => `${k}: ""`).join(", ")} })`);
112
+ }
113
+ const names = declaration?.names ?? [];
114
+ const have = new Set(Object.keys(values ?? {}));
115
+ return {
116
+ dir: resolve(dir), declaration, values,
117
+ provided: names.filter((n) => have.has(n)),
118
+ unprovided: names.filter((n) => !have.has(n)),
119
+ undeclared: [...have].filter((n) => !names.includes(n)),
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Puts the local values into an environment (the process's, for `voidbase serve` and `voidbase deploy`), never over
125
+ * a value that is already there. Returns what to tell the user: names loaded, names still missing, names nobody
126
+ * declared.
127
+ */
128
+ export function loadSecrets(dir = SECRETS_DIR, into: Record<string, string | undefined> = process.env): { loaded: string[]; missing: string[]; undeclared: string[]; state: SecretsState } {
129
+ const state = secretsState(dir);
130
+ const loaded: string[] = [];
131
+ for (const [k, v] of Object.entries(state.values ?? {})) { if (into[k] === undefined || into[k] === "") { into[k] = v; loaded.push(k); } }
132
+ const missing = state.unprovided.filter((n) => !into[n]);
133
+ return { loaded, missing, undeclared: state.undeclared, state };
134
+ }
135
+
136
+ // ---- the Worker's secrets, through the Workers API (what `wrangler secret put` calls) -----------------------------
137
+
138
+ /** The names of the secrets a Worker has; an empty list when the Worker does not exist yet. */
139
+ export async function workerSecretNames(api: CfApi, account: string, worker: string): Promise<string[]> {
140
+ // 10007: no such script yet (the first deploy creates it); CfApi tolerates error codes, not HTTP statuses
141
+ const res = await api.json<{ name: string; type: string }[]>("GET", `/accounts/${account}/workers/scripts/${encodeURIComponent(worker)}/secrets`, undefined, [10007]);
142
+ return (res.result ?? []).map((s) => s.name);
143
+ }
144
+
145
+ /** Stores secrets on a Worker, one call each (each becomes the current version's binding). The Worker must exist. */
146
+ export async function putWorkerSecrets(api: CfApi, account: string, worker: string, secrets: Record<string, string>): Promise<string[]> {
147
+ const done: string[] = [];
148
+ for (const [name, text] of Object.entries(secrets)) {
149
+ await api.json("PUT", `/accounts/${account}/workers/scripts/${encodeURIComponent(worker)}/secrets`, { name, text, type: "secret_text" });
150
+ done.push(name);
151
+ }
152
+ return done;
153
+ }
154
+
155
+ /** The scaffold `voidbase init` writes: a declaration with nothing in it yet, and how to fill it. */
156
+ export function declarationScaffold(): string {
157
+ return `/// <reference path="../pb_data/types.d.ts" />
158
+ // The secrets this app needs. This file is read by \`voidbase serve\` and \`voidbase deploy\`, never run: it names the
159
+ // secrets, and pb_secrets/secrets.json (git-ignored) holds their values on your machine:
160
+ //
161
+ // { "SMTP_PASSWORD": "..." }
162
+ //
163
+ // \`voidbase deploy\` stores the values as this Worker's secrets; \`voidbase secrets push\` does only that. A checkout
164
+ // without secrets.json (CI) deploys as long as every name below is already on the Worker. In hooks: $os.getenv("NAME").
165
+ secrets({
166
+ // SMTP_PASSWORD: "the mail provider's SMTP password",
167
+ });
168
+ `;
169
+ }
package/src/node/serve.ts CHANGED
@@ -9,7 +9,9 @@ import { assetsFetcher } from "./assets";
9
9
  import { ensurePanelDir } from "./panel";
10
10
  import { embedded } from "./embedded";
11
11
 
12
- export interface ServeOptions { http?: string; dir?: string; hooksDir?: string; migrationsDir?: string; publicDir?: string; quiet?: boolean }
12
+ export interface ServeOptions { http?: string; dir?: string; hooksDir?: string; migrationsDir?: string;
13
+ /** pb_secrets/: the declaration and the git-ignored values (VOIDBASE_SECRETS_DIR) */
14
+ secretsDir?: string; publicDir?: string; quiet?: boolean }
13
15
  const PKG = resolve(import.meta.dir, "../..");
14
16
 
15
17
  // system tables: the same SQL migrations Void applies on Cloudflare
@@ -37,6 +39,8 @@ export function applySystemMigrations(db: ReturnType<typeof openDatabase>, migra
37
39
  // Bun loads ./.env itself; a project that keeps its environment one level up (the SvelteKit starter) gets that too.
38
40
  // PB_* names from the PocketBase starter convention are accepted as aliases of the VOIDBASE_* ones.
39
41
  const ENV_ALIASES: Record<string, string> = { PB_SUPERUSER_EMAIL: "VOIDBASE_SUPERUSER_EMAIL", PB_SUPERUSER_PASSWORD: "VOIDBASE_SUPERUSER_PASSWORD", PB_USER_EMAIL: "VOIDBASE_USER_EMAIL", PB_USER_PASSWORD: "VOIDBASE_USER_PASSWORD", PB_ENCRYPTION_KEY: "VOIDBASE_ENCRYPTION_KEY" };
42
+ import { loadSecrets } from "./secrets";
43
+
40
44
  export function loadEnv(files = [".env", ".env.local", "../.env", "../.env.local"]): void {
41
45
  for (const f of files) {
42
46
  if (!existsSync(f)) continue;
@@ -50,18 +54,27 @@ export function loadEnv(files = [".env", ".env.local", "../.env", "../.env.local
50
54
  }
51
55
 
52
56
  export async function openLocal(opts: ServeOptions) {
57
+ // pb_secrets/secrets.json (git-ignored) into the environment before the .env files, so $os.getenv and the app see
58
+ // the same names as on Cloudflare, where the deploy stored them as the Worker's secrets (src/node/secrets.ts):
59
+ // the shell outranks secrets.json, which outranks a dev placeholder in .env
60
+ process.env.VOIDBASE_SECRETS_DIR = resolve(opts.secretsDir ?? process.env.VOIDBASE_SECRETS_DIR ?? "pb_secrets");
61
+ const secrets = loadSecrets(process.env.VOIDBASE_SECRETS_DIR);
53
62
  loadEnv();
54
63
  const dir = resolve(opts.dir ?? "pb_data");
55
64
  mkdirSync(dir, { recursive: true });
56
65
  process.env.VOIDBASE_HOOKS_DIR = resolve(opts.hooksDir ?? process.env.VOIDBASE_HOOKS_DIR ?? "pb_hooks");
57
66
  process.env.VOIDBASE_MIGRATIONS_DIR = resolve(opts.migrationsDir ?? process.env.VOIDBASE_MIGRATIONS_DIR ?? "pb_migrations");
67
+ if (secrets.missing.length && !opts.quiet) console.warn(`voidbase: ${secrets.missing.length} declared secret(s) have no value here (${process.env.VOIDBASE_SECRETS_DIR}/secrets.json): ${secrets.missing.join(", ")}`);
68
+ if (secrets.undeclared.length && !opts.quiet) console.warn(`voidbase: ${process.env.VOIDBASE_SECRETS_DIR}/secrets.json holds ${secrets.undeclared.join(", ")}, which main.pb.js does not declare; a deploy stores only declared secrets`);
58
69
  // pb_data/types.d.ts for editor support in pb_hooks (PocketBase's JSVM typings); a standalone executable carries
59
70
  // the typings and the system migrations itself (src/node/embedded.ts)
60
71
  const emb = await embedded();
61
72
  try { if (!existsSync(`${dir}/types.d.ts`)) writeFileSync(`${dir}/types.d.ts`, emb?.typesDts ?? readFileSync(`${PKG}/types/pb_data.d.ts`, "utf8")); } catch { /* optional */ }
62
73
  const sqlite = openDatabase(`${dir}/data.db`);
63
74
  applySystemMigrations(sqlite, emb?.migrations ?? readSystemMigrations());
64
- const env = { DB: d1(sqlite), STORAGE: fsBucket(`${dir}/storage`), ASSETS: assetsFetcher({ panelDir: await ensurePanelDir(), publicDir: opts.publicDir ? resolve(opts.publicDir) : undefined }) };
75
+ // PocketBase serves ./pb_public at / when the directory exists (--publicDir); a build there is a full static host
76
+ const publicDir = opts.publicDir ? resolve(opts.publicDir) : existsSync(resolve("pb_public")) ? resolve("pb_public") : undefined;
77
+ const env = { DB: d1(sqlite), STORAGE: fsBucket(`${dir}/storage`), ASSETS: assetsFetcher({ panelDir: await ensurePanelDir(), publicDir }) };
65
78
  return { dir, sqlite, env };
66
79
  }
67
80
 
package/src/server/api.ts CHANGED
@@ -4,8 +4,8 @@
4
4
  import type { Context, Hono } from "hono";
5
5
  import { app } from "./app";
6
6
  import { hookGlobals } from "./hooks";
7
- import type { RequestEvent } from "./hooks/runtime";
8
- import type { AppEnv } from "./types";
7
+ import { hookStore, type RequestEvent } from "./hooks/runtime";
8
+ import type { AppEnv, Bindings } from "./types";
9
9
 
10
10
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
11
11
  export type HookGlobals = Record<string, any>;
@@ -20,6 +20,11 @@ export interface VoidbaseApp {
20
20
  /** $app, $apis, $os, $security, routerAdd, routerUse, cronAdd, cronRemove, on* event registrations, Record, Collection, ... */
21
21
  hooks: HookGlobals;
22
22
  }
23
+ /** The bindings of the request, cron tick or migration currently running (the hook store's env), or undefined
24
+ * outside one. Extensions that are handed no context (a cron callback, a queue consumer) read them here. */
25
+ export function currentBindings(): Bindings | undefined {
26
+ return hookStore.getStore()?.env as Bindings | undefined;
27
+ }
23
28
  export function appApi(): VoidbaseApp {
24
29
  const hooks = hookGlobals() as HookGlobals;
25
30
  const late = (method: string): LateRegister => (path, handler) => hooks.routerAdd(method, path, (e: RequestEvent) => handler(e.c));
package/src/server/app.ts CHANGED
@@ -7,7 +7,7 @@ import type { Field } from "./collections/fields";
7
7
  import { createRecord, deleteRecord, listRecords, updateRecord, viewRecord, type ListQuery, type RecordContext } from "./records/service";
8
8
  import { fromColumn, toColumn } from "./records/values";
9
9
  import { expandRecords } from "./records/expand";
10
- import { hookGlobals, hookMiddleware, loadHooks, mountHookRoutes } from "./hooks";
10
+ import { globalHookMiddleware, hookGlobals, hookMiddleware, loadHooks, mountHookRoutes } from "./hooks";
11
11
  import { requestHook, requestHookResult, trigger } from "./hooks/runtime";
12
12
  import { logger } from "#platform/log";
13
13
  import { env as voidEnv } from "#platform/env";
@@ -46,6 +46,7 @@ import { ApiError, badRequest, forbidden, notFound } from "./errors";
46
46
  import { randomIdSuffix, randomString } from "./ids";
47
47
  import { createCollection, deleteCollection, importCollections, inferViewFields, truncateCollection, updateCollection } from "./collections/service";
48
48
  import { loadSettings, publicSettings } from "./settings";
49
+ import { mountWebAuthn } from "./webauthn";
49
50
  import type { AppEnv, Row } from "./types";
50
51
 
51
52
  export const app = new Hono<AppEnv>();
@@ -79,6 +80,8 @@ app.use("*", requestLogger());
79
80
  app.use("*", bodyLimitMiddleware());
80
81
  app.use("*", rateLimitMiddleware());
81
82
  app.use("*", hookMiddleware() as never);
83
+ // PocketBase's routerUse: the app's own global middleware, around every request (see src/adapter for Void's middleware/)
84
+ app.use("*", globalHookMiddleware() as never);
82
85
 
83
86
  app.onError((err, c) => {
84
87
  if (err instanceof ApiError) return err.response();
@@ -471,6 +474,8 @@ function sortBy<T extends object>(items: T[], sort: string, allowed: string[]):
471
474
  }
472
475
 
473
476
  // --- passkeys (the starter's Go webauthn routes, native here) ---------------
477
+ // mounted for every app; the routes answer only where a `passkeys` collection exists
478
+ mountWebAuthn(app);
474
479
  mountOAuth2Redirect(app);
475
480
  mountSettingsApi(app);
476
481
  const authDeps = {
@@ -3,12 +3,13 @@ import { logger } from "#platform/log";
3
3
  import type { Hono, MiddlewareHandler } from "hono";
4
4
  import { files, hooks, hooksDir, modules } from "#platform/hooks";
5
5
  import { loadCollections } from "../collections/model";
6
+ import { dispatch, registerJobHandler, type Job } from "../jobs";
6
7
  import { loadSettings } from "../settings";
7
8
  import type { AppEnv } from "../types";
8
9
  import { CollectionRef, HookRecord } from "./record";
9
10
  import {
10
11
  $apis, $app, $dbx, $filesystem, $http, $security, BadRequestError, ForbiddenError, InternalServerError, MailerMessage, NotFoundError,
11
- RecordUpsertFormFactory, RequestEvent, UnauthorizedError, ValidationError, authToHookRecord, cronAdd, cronRemove, hookStore,
12
+ RecordUpsertFormFactory, RequestEvent, UnauthorizedError, ValidationError, authToHookRecord, cronAdd, cronRemove, globalMiddlewares, hookStore,
12
13
  crons, eventHooks, makeOs, onEvent, routerAdd, routerUse, routes, type HookMiddleware,
13
14
  } from "./runtime";
14
15
  import { ApiError } from "../errors";
@@ -28,6 +29,10 @@ function buildGlobals(): Record<string, unknown> {
28
29
  const g: Record<string, unknown> = {
29
30
  $app, $apis, $http, $os, $filesystem, $security,
30
31
  $mails: {}, $template: { loadFiles: () => ({ render: () => "" }) }, $dbx,
32
+ // voidbase extensions a hook cannot get at otherwise: the Cloudflare bindings of the request, cron tick or
33
+ // migration running now, and the background jobs queue
34
+ $env: () => (hookStore.getStore()?.env ?? {}) as Record<string, unknown>,
35
+ $jobs: { queueJob: (job: Job) => dispatch(job), onJob: (type: Job["type"], fn: Parameters<typeof registerJobHandler>[1]) => registerJobHandler(type, fn) },
31
36
  routerAdd, routerUse, cronAdd, cronRemove,
32
37
  migrate: () => { /* migrations are applied by the migrations runner, not at hook load */ },
33
38
  Record: class Record extends HookRecord { constructor(collection: CollectionRef, data?: { [k: string]: unknown }) { super(collection, data ?? {}); } },
@@ -136,6 +141,27 @@ export function mountHookRoutes(app: Hono<AppEnv>) {
136
141
  });
137
142
  }
138
143
 
144
+ /** PocketBase's routerUse middleware, around every request voidbase serves (its own endpoints included). */
145
+ export function globalHookMiddleware(): MiddlewareHandler<AppEnv> {
146
+ return async (c, next) => {
147
+ if (!globalMiddlewares.length) return next();
148
+ const ev = new RequestEvent(c, authToHookRecord(c.get("auth")));
149
+ let i = 0, reachedRoute = false;
150
+ const step = async (): Promise<unknown> => {
151
+ const mw = globalMiddlewares[i++];
152
+ if (!mw) { reachedRoute = true; await next(); return undefined; }
153
+ return (typeof mw === "function" ? mw : mw.func)(ev);
154
+ };
155
+ ev.next = step;
156
+ const result = await step();
157
+ if (result instanceof Response) return result;
158
+ if (ev.written) return ev.written;
159
+ // a middleware that stopped the chain without answering: an empty 204, as a hook route in the same state gets
160
+ if (!reachedRoute) return c.body(null, 204);
161
+ return undefined;
162
+ };
163
+ }
164
+
139
165
  // Per-request state for $app and friends.
140
166
  export function hookMiddleware(): MiddlewareHandler<AppEnv> {
141
167
  return async (c, next) => {
@@ -5,7 +5,7 @@
5
5
  import { migrations } from "#platform/migrations";
6
6
  import { invalidateCollections, loadCollections } from "../collections/model";
7
7
  import { importCollections } from "../collections/service";
8
- import { all, stmt } from "../db";
8
+ import { all, run, stmt } from "../db";
9
9
  import { loadSettings } from "../settings";
10
10
  import type { RecordContext } from "../records/service";
11
11
  import type { AppEnv } from "../types";
@@ -34,6 +34,9 @@ async function runPending(db: D1Database, globals: Record<string, unknown>): Pro
34
34
  const applied = new Set((await all<{ file: string }>(db, "SELECT file FROM `_pbMigrations`")).map((r) => r.file));
35
35
  const own: Record<string, unknown> = {
36
36
  importCollections: (list: Record<string, unknown>[], deleteMissing = false) => importCollections(db, list, deleteMissing),
37
+ // raw DDL/DML for migrations that are not about collections (a Void app's Drizzle migrations, see src/adapter):
38
+ // voidbase-specific, PocketBase spells this app.db().newQuery(sql).execute()
39
+ execSQL: (sql: string, params: unknown[] = []) => run(db, sql, params),
37
40
  };
38
41
  const $app = (globals.$app ?? {}) as Record<string, unknown>;
39
42
  const app = new Proxy(own, { get: (t, k) => (k in t ? t[k as string] : $app[k as string]), has: (t, k) => k in t || k in $app });
@@ -50,6 +53,8 @@ async function runPending(db: D1Database, globals: Record<string, unknown>): Pro
50
53
  throw err;
51
54
  }
52
55
  invalidateCollections();
56
+ // the next migration must see what this one created (PocketBase reloads its collections cache the same way)
57
+ const store = hookStore.getStore(); if (store) { const fresh = await loadCollections(db); store.collections.clear(); for (const [k, v] of fresh) store.collections.set(k, v); }
53
58
  await stmt(db, "INSERT OR IGNORE INTO `_pbMigrations` (file, applied) VALUES (?, ?)", [m.name, Date.now()]).run();
54
59
  done.push(m.name);
55
60
  }
@@ -1,6 +1,10 @@
1
1
  // The JSVM-compatible global API for pb_hooks files, plus the registries their calls populate.
2
2
  // Per-request state ($app's database, the request) is carried by AsyncLocalStorage.
3
3
  import { AsyncLocalStorage } from "node:async_hooks";
4
+ // A generated pb_hooks bundle (src/adapter) cannot import anything: the hook sandbox resolves only its sibling
5
+ // files. Void's runtime needs AsyncLocalStorage, so the bundle reads it here, from the one place both runtimes
6
+ // already have it (Bun natively, Workers through nodejs_compat).
7
+ (globalThis as { AsyncLocalStorage?: unknown }).AsyncLocalStorage ??= AsyncLocalStorage;
4
8
  import type { Context } from "hono";
5
9
  import type { Collection } from "../collections/model";
6
10
  import { ApiError } from "../errors";
@@ -299,7 +303,9 @@ export const $security = {
299
303
 
300
304
  export function makeOs(files: Record<string, string>, hooksDir: string) {
301
305
  return {
302
- getenv: (name: string) => { const v = store()?.env[name]; return v == null ? "" : String(v); },
306
+ // the bindings first (a Worker's vars and secrets), then the process environment (Bun; a Worker has none
307
+ // without nodejs_compat), which is what PocketBase's $os.getenv reads
308
+ getenv: (name: string) => { const v = store()?.env[name]; if (v != null) return String(v); const p = (globalThis as { process?: { env?: Record<string, string | undefined> } }).process?.env?.[name]; return p == null ? "" : String(p); },
303
309
  readFile: (path: string) => {
304
310
  const rel = path.startsWith(hooksDir) ? path.slice(hooksDir.length).replace(/^\/+/, "") : path;
305
311
  const text = files[rel];
@@ -316,7 +322,9 @@ export function makeOs(files: Record<string, string>, hooksDir: string) {
316
322
  export function routerAdd(method: string, path: string, handler: HookFn, ...middlewares: HookMiddleware[]) {
317
323
  routes.push({ method: method.toUpperCase() === "ANY" ? "ALL" : method.toUpperCase(), path: toHonoPath(path), handler, middlewares });
318
324
  }
319
- export function routerUse(..._middlewares: HookMiddleware[]) { /* global hook middleware: milestone six */ }
325
+ /** Global middleware, PocketBase's `routerUse`: runs on every request, before the route that answers it. */
326
+ export const globalMiddlewares: HookMiddleware[] = [];
327
+ export function routerUse(...middlewares: HookMiddleware[]) { globalMiddlewares.push(...middlewares); }
320
328
  export function cronAdd(id: string, expr: string, fn: () => unknown) { crons.set(id, { expr, fn }); }
321
329
  export function cronRemove(id: string) { crons.delete(id); }
322
330
 
@@ -8,7 +8,9 @@ import type { Bindings } from "./types";
8
8
 
9
9
  export type Job =
10
10
  | { type: "mail"; message: MailMessage; text: string }
11
- | { type: "backup"; name: string };
11
+ | { type: "backup"; name: string }
12
+ // a message for one of the app's own queues (a Void app's queues/<name>.ts, mounted by src/adapter)
13
+ | { type: "queue"; queue: string; body: unknown };
12
14
  export type JobHandler<T extends Job = Job> = (env: Bindings, job: T) => Promise<void>;
13
15
 
14
16
  const handlers = new Map<Job["type"], JobHandler>();