@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.
- package/CHANGELOG.md +47 -0
- package/README.md +15 -7
- package/bin/voidbase.ts +58 -6
- package/docs/adapter.md +271 -0
- package/docs/ci.md +195 -0
- package/docs/deploy.md +60 -5
- package/docs/releasing.md +39 -31
- package/hooks-plugin.ts +15 -4
- package/package.json +9 -4
- package/routes/api/[...path].ts +0 -5
- package/scripts/cf-builds.ts +228 -0
- package/scripts/ci-browser.sh +60 -0
- package/scripts/ci-cache.sh +40 -0
- package/scripts/ci-lib.sh +40 -0
- package/scripts/ci-oracles.sh +19 -0
- package/scripts/ci-plan.ts +270 -0
- package/scripts/ci-status.ts +126 -0
- package/scripts/ci-suites.sh +11 -1
- package/scripts/ci.sh +188 -0
- package/scripts/gh-release.ts +48 -0
- package/scripts/release.sh +104 -0
- package/scripts/seed-reference.sh +7 -2
- package/scripts/sync-app.ts +1 -0
- package/src/adapter/bundle.ts +130 -0
- package/src/adapter/codegen.ts +269 -0
- package/src/adapter/index.ts +6 -0
- package/src/adapter/plugin.ts +117 -0
- package/src/adapter/runtime.ts +325 -0
- package/src/adapter/scan.ts +276 -0
- package/src/cloud/rest.ts +10 -2
- package/src/node/assets.ts +7 -1
- package/src/node/cloud-init.ts +14 -0
- package/src/node/deploy-cf.ts +113 -16
- package/src/node/secrets.ts +169 -0
- package/src/node/serve.ts +15 -2
- package/src/server/api.ts +7 -2
- package/src/server/app.ts +6 -1
- package/src/server/hooks/index.ts +27 -1
- package/src/server/hooks/migrations.ts +4 -1
- package/src/server/hooks/runtime.ts +10 -2
- package/src/server/jobs.ts +3 -1
- package/src/server/webauthn.ts +23 -6
- package/tsconfig.json +4 -0
- package/tsconfig.node.json +2 -1
package/src/node/cloud-init.ts
CHANGED
|
@@ -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";
|
package/src/node/deploy-cf.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
97
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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 ${
|
|
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${
|
|
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 (
|
|
167
|
-
|
|
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
|
-
|
|
171
|
-
const d = await attachCustomDomain(api, account.id, { hostname:
|
|
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;
|
|
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
|
-
|
|
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
|
|
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 });
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
package/src/server/jobs.ts
CHANGED
|
@@ -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>();
|