@voidbase-cloud/voidbase 0.1.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/.env.example +9 -0
- package/CHANGELOG.md +19 -0
- package/COMPAT.md +43 -0
- package/LICENSE +21 -0
- package/NOTICE +8 -0
- package/README.md +124 -0
- package/bin/voidbase.ts +158 -0
- package/crons/every-minute.ts +13 -0
- package/db/migrations/20260905175935_large_swarm.sql +87 -0
- package/db/migrations/20260905185720_wild_sunspot.sql +16 -0
- package/db/migrations/20260905190723_solid_toro.sql +1 -0
- package/db/migrations/20260905213340_remarkable_union_jack.sql +11 -0
- package/db/migrations/meta/20260905175935_snapshot.json +599 -0
- package/db/migrations/meta/20260905185720_snapshot.json +703 -0
- package/db/migrations/meta/20260905190723_snapshot.json +710 -0
- package/db/migrations/meta/20260905213340_snapshot.json +781 -0
- package/db/migrations/meta/_journal.json +34 -0
- package/db/schema.ts +130 -0
- package/docs/deploy.md +153 -0
- package/docs/differences.md +88 -0
- package/docs/hooks.md +84 -0
- package/docs/migrating.md +29 -0
- package/docs/perf.md +53 -0
- package/docs/platform.md +208 -0
- package/docs/releasing.md +38 -0
- package/env.ts +23 -0
- package/hooks-plugin.ts +237 -0
- package/package.json +134 -0
- package/queues/jobs.ts +13 -0
- package/routes/api/[...path].ts +19 -0
- package/scripts/bench-realtime.ts +46 -0
- package/scripts/bench.ts +39 -0
- package/scripts/ci-suites.sh +27 -0
- package/scripts/dev.sh +29 -0
- package/scripts/export.ts +70 -0
- package/scripts/seed-app-user.sh +14 -0
- package/scripts/seed-d1.ts +17 -0
- package/scripts/seed-reference.sh +29 -0
- package/scripts/starter.sh +22 -0
- package/scripts/sync-app.ts +22 -0
- package/scripts/sync-panel.ts +66 -0
- package/src/cloud/rest.ts +297 -0
- package/src/node/assets.ts +22 -0
- package/src/node/bundle.ts +88 -0
- package/src/node/cloud-init.ts +51 -0
- package/src/node/d1.ts +44 -0
- package/src/node/deploy-cf.ts +179 -0
- package/src/node/index.ts +5 -0
- package/src/node/panel.ts +21 -0
- package/src/node/serve.ts +125 -0
- package/src/node/storage.ts +51 -0
- package/src/platform/node/env.ts +4 -0
- package/src/platform/node/hooks.ts +19 -0
- package/src/platform/node/log.ts +7 -0
- package/src/platform/node/migrations.ts +5 -0
- package/src/platform/node/photon.ts +1 -0
- package/src/platform/node/sockets.ts +22 -0
- package/src/platform/node/sse.ts +23 -0
- package/src/platform/workers/env.ts +3 -0
- package/src/platform/workers/hooks.ts +2 -0
- package/src/platform/workers/log.ts +1 -0
- package/src/platform/workers/migrations.ts +1 -0
- package/src/platform/workers/photon.ts +1 -0
- package/src/platform/workers/sockets.ts +3 -0
- package/src/platform/workers/sse.ts +1 -0
- package/src/server/api.ts +27 -0
- package/src/server/app.ts +582 -0
- package/src/server/auth-extra.ts +113 -0
- package/src/server/auth-flows.ts +186 -0
- package/src/server/auth-response.ts +111 -0
- package/src/server/auth.ts +187 -0
- package/src/server/backups.ts +234 -0
- package/src/server/batch.ts +123 -0
- package/src/server/bootstrap.ts +71 -0
- package/src/server/collections/auth-option-shape.json +71 -0
- package/src/server/collections/ddl.ts +127 -0
- package/src/server/collections/fields.ts +120 -0
- package/src/server/collections/model.ts +185 -0
- package/src/server/collections/oauth2-providers.json +1 -0
- package/src/server/collections/scaffolds.json +210 -0
- package/src/server/collections/service.ts +392 -0
- package/src/server/collections/system.json +605 -0
- package/src/server/collections/system.ts +19 -0
- package/src/server/collections/validate.ts +239 -0
- package/src/server/crc32.ts +13 -0
- package/src/server/crons.ts +100 -0
- package/src/server/crypto.ts +26 -0
- package/src/server/db.ts +37 -0
- package/src/server/errors.ts +53 -0
- package/src/server/files-api.ts +52 -0
- package/src/server/filter/compile.ts +420 -0
- package/src/server/filter/lexer.ts +107 -0
- package/src/server/filter/parser.ts +49 -0
- package/src/server/hardening.ts +136 -0
- package/src/server/hooks/index.ts +147 -0
- package/src/server/hooks/migrations.ts +58 -0
- package/src/server/hooks/node-async-hooks.d.ts +7 -0
- package/src/server/hooks/record.ts +152 -0
- package/src/server/hooks/runtime.ts +344 -0
- package/src/server/hooks/virtual-migrations.d.ts +4 -0
- package/src/server/hooks/virtual.d.ts +7 -0
- package/src/server/hub.ts +91 -0
- package/src/server/ids.ts +22 -0
- package/src/server/jobs.ts +84 -0
- package/src/server/jwt.ts +61 -0
- package/src/server/logs.ts +144 -0
- package/src/server/mail/index.ts +99 -0
- package/src/server/mail/message.ts +43 -0
- package/src/server/mail/smtp.ts +82 -0
- package/src/server/mail/templates.ts +168 -0
- package/src/server/oauth2/index.ts +198 -0
- package/src/server/oauth2/providers.ts +153 -0
- package/src/server/password.ts +17 -0
- package/src/server/realtime/hub-client.ts +50 -0
- package/src/server/realtime/index.ts +239 -0
- package/src/server/records/expand.ts +129 -0
- package/src/server/records/files.ts +69 -0
- package/src/server/records/json.ts +23 -0
- package/src/server/records/picker.ts +80 -0
- package/src/server/records/service.ts +598 -0
- package/src/server/records/thumbs.ts +148 -0
- package/src/server/records/values.ts +295 -0
- package/src/server/settings-api.ts +104 -0
- package/src/server/settings.ts +215 -0
- package/src/server/sql.ts +61 -0
- package/src/server/static.ts +17 -0
- package/src/server/storage/s3.ts +118 -0
- package/src/server/types.ts +25 -0
- package/src/server/webauthn.ts +168 -0
- package/tsconfig.json +36 -0
- package/tsconfig.node.json +27 -0
- package/types/pb_data.d.ts +24438 -0
- package/vite.config.ts +10 -0
- package/void.json +12 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// `voidbase deploy`: go live on your own Cloudflare account with one API token (VOIDBASE_DEPLOY_CF_API_KEY).
|
|
2
|
+
// Resolves the account, creates the D1 database, the R2 bucket and the jobs queue over the REST API (idempotent),
|
|
3
|
+
// writes the Void project (cloud/) with a wrangler.jsonc carrying the real ids plus the rate-limit and Analytics
|
|
4
|
+
// Engine bindings, stores the superuser credentials as worker secrets and runs `void deploy --backend cloudflare`,
|
|
5
|
+
// which builds, applies the D1 migrations and uploads the Worker.
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { resolve } from "node:path";
|
|
8
|
+
import { writeCloudProject } from "./cloud-init";
|
|
9
|
+
import { loadEnv } from "./serve";
|
|
10
|
+
import { CfApi, attachCustomDomain, ensureD1, ensureQueue, ensureR2, rateLimitNamespace, resolveAccount, workersSubdomain } from "../cloud/rest";
|
|
11
|
+
|
|
12
|
+
const API = (process.env.CLOUDFLARE_API_BASE ?? "https://api.cloudflare.com/client/v4").replace(/\/$/, "");
|
|
13
|
+
export const TOKEN_ENV = "VOIDBASE_DEPLOY_CF_API_KEY";
|
|
14
|
+
// Account-owned token template (docs: fundamentals/api/how-to/account-owned-token-template): the dashboard resolves
|
|
15
|
+
// :account to the signed-in account and pre-selects exactly what the deploy needs.
|
|
16
|
+
export const TOKEN_PERMISSIONS = [
|
|
17
|
+
{ key: "workers_scripts", type: "edit" }, // upload the Worker, its cron trigger and secrets
|
|
18
|
+
{ key: "d1", type: "edit" }, // create the database, apply migrations
|
|
19
|
+
{ key: "workers_r2", type: "edit" }, // create the files bucket
|
|
20
|
+
{ key: "queues", type: "edit" }, // create the jobs queue (mail and backups with retries); optional
|
|
21
|
+
{ key: "account_settings", type: "read" }, // resolve the account id and workers.dev subdomain
|
|
22
|
+
];
|
|
23
|
+
export const tokenDeepLink = () => `https://dash.cloudflare.com/?to=/:account/api-tokens&permissionGroupKeys=${encodeURIComponent(JSON.stringify(TOKEN_PERMISSIONS))}&name=${encodeURIComponent(TOKEN_ENV)}`;
|
|
24
|
+
export const tokenHelp = () => `Create the deploy token in the Cloudflare dashboard (permissions pre-selected):
|
|
25
|
+
|
|
26
|
+
${tokenDeepLink()}
|
|
27
|
+
|
|
28
|
+
Then make it available as ${TOKEN_ENV} (shell export, .env next to pb_hooks, or a CI secret) and run: voidbase deploy
|
|
29
|
+
Permissions the link pre-selects: Workers Scripts (edit), D1 (edit), Workers R2 Storage (edit), Queues (edit),
|
|
30
|
+
Account Settings (read). Queues is optional: without it the deploy skips the jobs queue and sends mail inline.
|
|
31
|
+
If the link format ever changes, pick those by hand at https://dash.cloudflare.com/?to=/:account/api-tokens
|
|
32
|
+
(reference: https://developers.cloudflare.com/fundamentals/api/reference/permissions/).`;
|
|
33
|
+
|
|
34
|
+
export interface DeployOptions { cron?: boolean; domain?: string; name?: string; account?: string; dir?: string; // dir: a visible project instead of <package>/.cloud/<slug>
|
|
35
|
+
publicDir?: string; dryRun?: boolean; regenerate?: boolean; superuserEmail?: string; superuserPassword?: string; log?: (line: string) => void;
|
|
36
|
+
queue?: boolean; // jobs queue for mail and automatic backups (default on; skipped when the token cannot create queues)
|
|
37
|
+
analytics?: boolean; // Analytics Engine dataset with one data point per request (opt-in: --analytics or VOIDBASE_DEPLOY_ANALYTICS=1; the account must have Analytics Engine enabled)
|
|
38
|
+
rateLimit?: string; // exact per-location ceiling per IP as "<requests>/<10|60>", default "300/10" (PocketBase's /api/ rule); "0" disables
|
|
39
|
+
hub?: boolean; // realtime hub Durable Object in this Worker (default on; VOIDBASE_DEPLOY_HUB=0 keeps the D1 poll)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// the Cloudflare calls live in src/cloud/rest.ts (shared with control planes); this module adds the deploy's own wording
|
|
43
|
+
export { rateLimitNamespace } from "../cloud/rest";
|
|
44
|
+
export function parseRateLimit(spec: string | undefined): { limit: number; period: 10 | 60 } | null {
|
|
45
|
+
const v = (spec ?? "").trim();
|
|
46
|
+
if (!v || v === "0" || v === "off") return v ? null : { limit: 300, period: 10 };
|
|
47
|
+
const m = /^(\d+)\/(10|60)$/.exec(v);
|
|
48
|
+
if (!m) throw new Error(`invalid rate limit "${spec}": use <requests>/10 or <requests>/60 (seconds), or 0 to disable`);
|
|
49
|
+
return { limit: Number(m[1]), period: Number(m[2]) as 10 | 60 };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const slug = (s: string) => s.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 54) || "voidbase";
|
|
53
|
+
function projectName(): string {
|
|
54
|
+
try { const n = String((JSON.parse(readFileSync("package.json", "utf8")) as { name?: string }).name ?? ""); if (n && n !== "vb" && n !== "pb") return slug(n); } catch { /* no package.json */ }
|
|
55
|
+
const dir = slug(resolve(".").split("/").at(-1) ?? "");
|
|
56
|
+
return dir === "vb" || dir === "pb" ? slug(`${resolve("..").split("/").at(-1) ?? "voidbase"}-backend`) : dir;
|
|
57
|
+
}
|
|
58
|
+
const randomPassword = () => { const a = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"; const b = crypto.getRandomValues(new Uint8Array(20)); return Array.from(b, (x) => a[x % a.length]).join(""); };
|
|
59
|
+
|
|
60
|
+
// Bun only loads the .env of the working directory; the starter keeps its PB_* and deploy variables one level up.
|
|
61
|
+
const ENV_KEYS = [TOKEN_ENV, "VOIDBASE_DEPLOY_CF_ACCOUNT_ID", "VOIDBASE_DEPLOY_NAME", "VOIDBASE_DEPLOY_QUEUE", "VOIDBASE_DEPLOY_HUB", "VOIDBASE_DEPLOY_ANALYTICS", "VOIDBASE_DEPLOY_RATE_LIMIT", "VOIDBASE_SUPERUSER_EMAIL", "VOIDBASE_SUPERUSER_PASSWORD", "PB_SUPERUSER_EMAIL", "PB_SUPERUSER_PASSWORD", "AUDITLOG"];
|
|
62
|
+
export function loadEnvFiles(files = [".env", ".env.local", "../.env", "../.env.local"]): string[] {
|
|
63
|
+
const loaded: string[] = [];
|
|
64
|
+
for (const f of files) {
|
|
65
|
+
if (!existsSync(f)) continue;
|
|
66
|
+
for (const line of readFileSync(f, "utf8").split("\n")) {
|
|
67
|
+
const m = /^\s*(?:export\s+)?([A-Z0-9_]+)\s*=\s*(.*?)\s*$/.exec(line); if (!m) continue;
|
|
68
|
+
const [, k, raw] = m as unknown as [string, string, string];
|
|
69
|
+
if (!ENV_KEYS.includes(k) || process.env[k]) continue;
|
|
70
|
+
process.env[k] = raw.replace(/^(['"])(.*)\1$/, "$2"); loaded.push(`${k} (${f})`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return loaded;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function deployToCloudflare(opts: DeployOptions = {}): Promise<{ name: string; account: string; url: string | null; wranglerConfig: string; project: string }> {
|
|
77
|
+
const log = opts.log ?? ((l: string) => console.log(l));
|
|
78
|
+
loadEnv(); const fromFiles = loadEnvFiles(); if (fromFiles.length) log(`from .env: ${fromFiles.join(", ")}`);
|
|
79
|
+
const token = process.env[TOKEN_ENV] || process.env.CLOUDFLARE_API_TOKEN || ""; // empty means unset
|
|
80
|
+
if (!token) { log(`${TOKEN_ENV} is not set.\n\n${tokenHelp()}`); throw new Error(`${TOKEN_ENV} missing`); }
|
|
81
|
+
const name = slug(opts.name || process.env.VOIDBASE_DEPLOY_NAME || projectName());
|
|
82
|
+
const api = new CfApi(token, API);
|
|
83
|
+
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?)`); });
|
|
84
|
+
log(`account ${account.name} (${account.id}), worker "${name}"`);
|
|
85
|
+
const db = await ensureD1(api, account.id, `${name}-db`); log(`D1 ${name}-db ${db.created ? "created" : "exists"} (${db.uuid})`);
|
|
86
|
+
const bucket = await ensureR2(api, account.id, `${name}-storage`); log(`R2 ${name}-storage ${bucket.created ? "created" : "exists"}`);
|
|
87
|
+
const off = (v: string | undefined) => v !== undefined && ["0", "false", "off", "no"].includes(v.trim().toLowerCase());
|
|
88
|
+
const wantQueue = opts.queue ?? !off(process.env.VOIDBASE_DEPLOY_QUEUE);
|
|
89
|
+
const on = (v: string | undefined) => v !== undefined && ["1", "true", "on", "yes"].includes(v.trim().toLowerCase());
|
|
90
|
+
const analytics = opts.analytics ?? on(process.env.VOIDBASE_DEPLOY_ANALYTICS);
|
|
91
|
+
const rateLimit = parseRateLimit(opts.rateLimit ?? process.env.VOIDBASE_DEPLOY_RATE_LIMIT);
|
|
92
|
+
const hub = opts.hub ?? !off(process.env.VOIDBASE_DEPLOY_HUB);
|
|
93
|
+
// Workers Free allows 5 cron triggers per account; without the trigger PocketBase's maintenance runs lazily in requests
|
|
94
|
+
const cron = opts.cron ?? !off(process.env.VOIDBASE_DEPLOY_CRON);
|
|
95
|
+
// 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}"`);
|
|
98
|
+
let queue: string | false = false;
|
|
99
|
+
if (wantQueue) {
|
|
100
|
+
const q = await ensureQueue(api, account.id, `${name}-jobs`);
|
|
101
|
+
queue = q.id ? `${name}-jobs` : false;
|
|
102
|
+
if (queue) log(`Queue ${name}-jobs ${q.created ? "created" : "exists"} (mail and automatic backups run from it with retries)`);
|
|
103
|
+
else log(`Queue ${name}-jobs not created (${q.reason}): mail is sent inline and backups run in the cron tick. Give the token the Queues edit permission (${tokenDeepLink()}) to enable it, or VOIDBASE_DEPLOY_QUEUE=0 to silence this.`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// the Void project lives inside the voidbase package (<package>/.cloud/<slug>), not in the consumer's tree:
|
|
107
|
+
// its entry files import this package by relative path and resolve `void`/`vite` by walking up to node_modules
|
|
108
|
+
const PKG = resolve(import.meta.dir, "../.."); const cloud = opts.dir ? resolve(opts.dir) : resolve(PKG, ".cloud", name);
|
|
109
|
+
const consumer = resolve(".");
|
|
110
|
+
const entry = ["main.ts", "main.js"].map((f) => resolve(consumer, f)).find((f) => existsSync(f) && /export\s+(async\s+)?function\s+register\b|export\s*\{[^}]*\bregister\b/.test(readFileSync(f, "utf8")));
|
|
111
|
+
if (entry) log(`composing ${entry} (register) into the Worker`);
|
|
112
|
+
writeCloudProject(cloud, opts.dir ? "package" : "internal", { hooksDir: resolve(consumer, process.env.VOIDBASE_HOOKS_DIR || "pb_hooks"), migrationsDir: resolve(consumer, process.env.VOIDBASE_MIGRATIONS_DIR || "pb_migrations"), entry, queue, hub });
|
|
113
|
+
if (!queue) { const { rmSync } = await import("node:fs"); rmSync(`${cloud}/queues`, { recursive: true, force: true }); }
|
|
114
|
+
if (!cron) { const { rmSync } = await import("node:fs"); rmSync(`${cloud}/crons`, { recursive: true, force: true }); log("cron trigger disabled (VOIDBASE_DEPLOY_CRON=0 / --no-cron): maintenance runs lazily in requests"); }
|
|
115
|
+
// Smart Placement runs the Worker next to its D1 database: PocketBase-shaped requests are several dependent queries.
|
|
116
|
+
// The rate-limit binding is an exact per-location ceiling per IP on top of the settings' rules (which count per
|
|
117
|
+
// isolate); the Analytics Engine dataset takes one data point per request at any log level.
|
|
118
|
+
const wranglerConfig = JSON.stringify({
|
|
119
|
+
name, account_id: account.id, placement: { mode: "smart" },
|
|
120
|
+
...(domain ? { workers_dev: false } : {}), // the custom domain is attached through the API after the upload (see below)
|
|
121
|
+
d1_databases: [{ binding: "DB", database_name: `${name}-db`, database_id: db.uuid, migrations_dir: "./db/migrations" }],
|
|
122
|
+
r2_buckets: [{ binding: "STORAGE", bucket_name: `${name}-storage` }],
|
|
123
|
+
...(rateLimit ? { ratelimits: [{ name: "RATE_LIMITER", namespace_id: rateLimitNamespace(name), simple: { limit: rateLimit.limit, period: rateLimit.period } }] } : {}),
|
|
124
|
+
...(analytics ? { analytics_engine_datasets: [{ binding: "LOGS_ANALYTICS", dataset: `${name.replace(/-/g, "_")}_requests` }] } : {}),
|
|
125
|
+
// the realtime hub: a SQLite-backed Durable Object class exported from this Worker (free plan included), one per instance
|
|
126
|
+
...(hub ? { durable_objects: { bindings: [{ name: "HUB", class_name: "VoidbaseHub" }] }, migrations: [{ tag: "voidbase-hub-v1", new_sqlite_classes: ["VoidbaseHub"] }] } : {}),
|
|
127
|
+
}, null, 2) + "\n";
|
|
128
|
+
writeFileSync(`${cloud}/wrangler.jsonc`, `// written by voidbase deploy; ids are real resources on account ${account.id}\n${wranglerConfig}`);
|
|
129
|
+
// non-secret worker vars: the instance's own name and account (a control plane needs them to find itself), the
|
|
130
|
+
// hooks' AUDITLOG, plus VOIDBASE_DEPLOY_VARS=A,B from the environment; secrets (VOIDBASE_DEPLOY_SECRETS=X,Y) never go here
|
|
131
|
+
const listed = (key: string) => (process.env[key] ?? "").split(",").map((k) => k.trim()).filter(Boolean);
|
|
132
|
+
const extraVars = listed("VOIDBASE_DEPLOY_VARS"), extraSecrets = listed("VOIDBASE_DEPLOY_SECRETS");
|
|
133
|
+
const baked: Record<string, string> = { VOIDBASE_WORKER_NAME: name, VOIDBASE_ACCOUNT_ID: account.id };
|
|
134
|
+
for (const k of ["AUDITLOG", ...extraVars]) if (process.env[k]) baked[k] = process.env[k]!;
|
|
135
|
+
writeFileSync(`${cloud}/.env`, Object.entries(baked).map(([k, v]) => `${k}=${v}\n`).join(""));
|
|
136
|
+
log(`project: ${cloud}`);
|
|
137
|
+
|
|
138
|
+
// superuser: from the environment (PB_* is what the starter's entrypoint uses) or generated once and kept in pb_data
|
|
139
|
+
const dataDir = resolve(consumer, process.env.VOIDBASE_DATA_DIR || "pb_data"); mkdirSync(dataDir, { recursive: true });
|
|
140
|
+
const credFile = `${dataDir}/.superuser-credentials`;
|
|
141
|
+
let email = opts.superuserEmail || process.env.VOIDBASE_SUPERUSER_EMAIL || process.env.PB_SUPERUSER_EMAIL || "";
|
|
142
|
+
let password = opts.superuserPassword || process.env.VOIDBASE_SUPERUSER_PASSWORD || process.env.PB_SUPERUSER_PASSWORD || "";
|
|
143
|
+
const saved = existsSync(credFile) ? (JSON.parse(readFileSync(credFile, "utf8")) as { email: string; password: string }) : null;
|
|
144
|
+
const placeholder = !password || password === "changeme123"; // the local dev default never goes live
|
|
145
|
+
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 });
|
|
149
|
+
|
|
150
|
+
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)`);
|
|
152
|
+
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 }; }
|
|
154
|
+
|
|
155
|
+
// the toolchain comes with the voidbase package (void, and wrangler through void)
|
|
156
|
+
const voidDir = resolve(Bun.resolveSync("void/package.json", PKG), "..");
|
|
157
|
+
const voidBin = resolve(voidDir, "..", ".bin", "void"); const wrangler = resolve(Bun.resolveSync("wrangler/package.json", voidDir), "..", "bin", "wrangler.js");
|
|
158
|
+
// values also exported in the shell are stripped from baked vars by the Cloudflare backend, so keep the vars file clean instead
|
|
159
|
+
// 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
|
+
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 };
|
|
162
|
+
for (const k of Object.keys(baked)) delete env[k];
|
|
163
|
+
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
|
+
mkdirSync(`${cloud}/public`, { recursive: true });
|
|
165
|
+
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]!])];
|
|
168
|
+
for (const [k, v] of secrets) await sh(["bun", wrangler, "secret", "put", k, "--name", name], v + "\n");
|
|
169
|
+
await sh([voidBin, "deploy", "--backend", "cloudflare"]);
|
|
170
|
+
if (domain) {
|
|
171
|
+
const d = await attachCustomDomain(api, account.id, { hostname: domain, service: name });
|
|
172
|
+
log(`custom domain ${d.hostname} ${d.created ? "attached" : "already attached"} (zone ${d.zone_id}); the certificate can take a minute`);
|
|
173
|
+
}
|
|
174
|
+
if (url) {
|
|
175
|
+
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})`);
|
|
177
|
+
} 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
|
+
return { name, account: account.id, url, wranglerConfig, project: cloud };
|
|
179
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// voidbase as a library (Bun): PocketBase's server as a process you compose in your own main.ts.
|
|
2
|
+
export { voidbase, serve, parseServeArgs, openLocal, type ServeOptions, type VoidbaseServer } from "./serve";
|
|
3
|
+
export type { VoidbaseApp, HookGlobals } from "../server/api";
|
|
4
|
+
export { RequestEvent } from "../server/hooks/runtime";
|
|
5
|
+
export { HookRecord, CollectionRef } from "../server/hooks/record";
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// Where the unmodified PocketBase admin panel comes from: a synced public/_ in this checkout, else the pinned
|
|
2
|
+
// release's committed ui/dist downloaded once into ~/.cache/voidbase.
|
|
3
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
export const PANEL_VERSION = process.env.POCKETBASE_PANEL_VERSION ?? "0.40.2";
|
|
6
|
+
export async function ensurePanelDir(): Promise<string> {
|
|
7
|
+
const local = [process.env.POCKETBASE_UI_DIST, resolve(import.meta.dir, "../../public/_"), resolve(import.meta.dir, "../../../pocketbase/ui/dist")].filter((p): p is string => !!p);
|
|
8
|
+
for (const p of local) if (existsSync(`${p}/index.html`)) return p;
|
|
9
|
+
const cache = resolve(`${process.env.XDG_CACHE_HOME ?? `${process.env.HOME}/.cache`}/voidbase/panel-${PANEL_VERSION}`);
|
|
10
|
+
if (existsSync(`${cache}/index.html`)) return cache;
|
|
11
|
+
console.log(`voidbase: downloading the PocketBase ${PANEL_VERSION} admin panel (ui/dist) into ${cache}`);
|
|
12
|
+
const res = await fetch(`https://codeload.github.com/pocketbase/pocketbase/tar.gz/refs/tags/v${PANEL_VERSION}`);
|
|
13
|
+
if (!res.ok) throw new Error(`panel download failed: HTTP ${res.status} (set POCKETBASE_UI_DIST to a local ui/dist)`);
|
|
14
|
+
mkdirSync(cache, { recursive: true });
|
|
15
|
+
const tgz = `${cache}.tgz`; writeFileSync(tgz, new Uint8Array(await res.arrayBuffer()));
|
|
16
|
+
const tar = Bun.spawnSync(["tar", "-xzf", tgz, "-C", cache, "--strip-components=3", `pocketbase-${PANEL_VERSION}/ui/dist`]);
|
|
17
|
+
rmSync(tgz, { force: true });
|
|
18
|
+
if (tar.exitCode !== 0) throw new Error(new TextDecoder().decode(tar.stderr));
|
|
19
|
+
writeFileSync(`${cache}/extensions.js`, "// voidbase: no UI extensions configured\n");
|
|
20
|
+
return cache;
|
|
21
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// `voidbase serve`: the PocketBase-shaped single process. The same Hono app that runs on Cloudflare, with D1 on
|
|
2
|
+
// bun:sqlite, R2 on the filesystem, SMTP on node sockets and the cron scheduler on a timer.
|
|
3
|
+
// import { serve } from "@voidbase-cloud/voidbase"; serve({ http: "127.0.0.1:8090", dir: "pb_data", publicDir: "../sk/build" });
|
|
4
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
5
|
+
import { resolve } from "node:path";
|
|
6
|
+
import { d1, openDatabase } from "./d1";
|
|
7
|
+
import { fsBucket } from "./storage";
|
|
8
|
+
import { assetsFetcher } from "./assets";
|
|
9
|
+
import { ensurePanelDir } from "./panel";
|
|
10
|
+
|
|
11
|
+
export interface ServeOptions { http?: string; dir?: string; hooksDir?: string; migrationsDir?: string; publicDir?: string; quiet?: boolean }
|
|
12
|
+
const PKG = resolve(import.meta.dir, "../..");
|
|
13
|
+
|
|
14
|
+
// system tables: the same SQL migrations Void applies on Cloudflare
|
|
15
|
+
export function applySystemMigrations(db: ReturnType<typeof openDatabase>): number {
|
|
16
|
+
db.exec("CREATE TABLE IF NOT EXISTS `_vb_migrations` (name TEXT PRIMARY KEY, applied TEXT NOT NULL)");
|
|
17
|
+
const done = new Set((db.query("SELECT name FROM `_vb_migrations`").all() as { name: string }[]).map((r) => r.name));
|
|
18
|
+
let applied = 0;
|
|
19
|
+
for (const f of readdirSync(`${PKG}/db/migrations`).filter((f) => f.endsWith(".sql")).sort()) {
|
|
20
|
+
if (done.has(f)) continue;
|
|
21
|
+
db.transaction(() => {
|
|
22
|
+
for (const statement of readFileSync(`${PKG}/db/migrations/${f}`, "utf8").split("--> statement-breakpoint")) if (statement.trim()) db.exec(statement);
|
|
23
|
+
db.query("INSERT INTO `_vb_migrations` (name, applied) VALUES (?, ?)").run(f, new Date().toISOString());
|
|
24
|
+
})();
|
|
25
|
+
applied++;
|
|
26
|
+
}
|
|
27
|
+
return applied;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Opens (and prepares) a data directory without serving: bindings for the CLI and for embedding.
|
|
31
|
+
// Bun loads ./.env itself; a project that keeps its environment one level up (the SvelteKit starter) gets that too.
|
|
32
|
+
// PB_* names from the PocketBase starter convention are accepted as aliases of the VOIDBASE_* ones.
|
|
33
|
+
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" };
|
|
34
|
+
export function loadEnv(files = [".env", ".env.local", "../.env", "../.env.local"]): void {
|
|
35
|
+
for (const f of files) {
|
|
36
|
+
if (!existsSync(f)) continue;
|
|
37
|
+
for (const line of readFileSync(f, "utf8").split("\n")) {
|
|
38
|
+
const m = /^\s*(?:export\s+)?([A-Z0-9_]+)\s*=\s*(.*?)\s*$/.exec(line); if (!m) continue;
|
|
39
|
+
const key = m[1]!; const value = m[2]!.replace(/^(['"])(.*)\1$/, "$2");
|
|
40
|
+
if (!process.env[key]) process.env[key] = value;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
for (const [alias, key] of Object.entries(ENV_ALIASES)) if (!process.env[key] && process.env[alias]) process.env[key] = process.env[alias];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function openLocal(opts: ServeOptions) {
|
|
47
|
+
loadEnv();
|
|
48
|
+
const dir = resolve(opts.dir ?? "pb_data");
|
|
49
|
+
mkdirSync(dir, { recursive: true });
|
|
50
|
+
process.env.VOIDBASE_HOOKS_DIR = resolve(opts.hooksDir ?? process.env.VOIDBASE_HOOKS_DIR ?? "pb_hooks");
|
|
51
|
+
process.env.VOIDBASE_MIGRATIONS_DIR = resolve(opts.migrationsDir ?? process.env.VOIDBASE_MIGRATIONS_DIR ?? "pb_migrations");
|
|
52
|
+
// pb_data/types.d.ts for editor support in pb_hooks (PocketBase's JSVM typings)
|
|
53
|
+
try { if (!existsSync(`${dir}/types.d.ts`)) copyFileSync(`${PKG}/types/pb_data.d.ts`, `${dir}/types.d.ts`); } catch { /* optional */ }
|
|
54
|
+
const sqlite = openDatabase(`${dir}/data.db`);
|
|
55
|
+
applySystemMigrations(sqlite);
|
|
56
|
+
const env = { DB: d1(sqlite), STORAGE: fsBucket(`${dir}/storage`), ASSETS: assetsFetcher({ panelDir: await ensurePanelDir(), publicDir: opts.publicDir ? resolve(opts.publicDir) : undefined }) };
|
|
57
|
+
return { dir, sqlite, env };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface VoidbaseServer { server: ReturnType<typeof Bun.serve>; env: Awaited<ReturnType<typeof openLocal>>["env"]; stop: () => void }
|
|
61
|
+
|
|
62
|
+
// The library entry: `const app = await voidbase(opts); register things; await app.start()` (main.go's shape).
|
|
63
|
+
export async function voidbase(opts: ServeOptions = {}) {
|
|
64
|
+
const { dir, env } = await openLocal(opts);
|
|
65
|
+
// the app module reads the hooks and migrations directories while loading
|
|
66
|
+
const { app } = await import("../server/app");
|
|
67
|
+
const { appApi } = await import("../server/api");
|
|
68
|
+
const api = appApi();
|
|
69
|
+
const start = async (): Promise<VoidbaseServer> => {
|
|
70
|
+
const [hostname, portStr] = (opts.http ?? "127.0.0.1:8090").split(":");
|
|
71
|
+
const port = Number(portStr ?? 8090);
|
|
72
|
+
const { runDue } = await import("../server/crons");
|
|
73
|
+
const { staticFallback } = await import("../server/static");
|
|
74
|
+
const ctx = { waitUntil: (p: Promise<unknown>) => { Promise.resolve(p).catch((e) => console.error("voidbase: background task failed", e)); }, passThroughOnException() {} };
|
|
75
|
+
const server = Bun.serve({
|
|
76
|
+
hostname, port, idleTimeout: 255,
|
|
77
|
+
async fetch(req) {
|
|
78
|
+
const res = await app.fetch(req, env, ctx as never);
|
|
79
|
+
if (res.status !== 404) return res;
|
|
80
|
+
return (await staticFallback(req, env.ASSETS)) ?? res;
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
// cron: every minute on the minute, like the Cloudflare trigger
|
|
84
|
+
const tick = () => runDue(env as never, new Date()).catch((e) => console.error("voidbase: cron failed", e));
|
|
85
|
+
const first = 60_000 - (Date.now() % 60_000);
|
|
86
|
+
const timer = setTimeout(() => { void tick(); setInterval(() => void tick(), 60_000); }, first);
|
|
87
|
+
if (!opts.quiet) {
|
|
88
|
+
const shown = hostname === "0.0.0.0" ? "127.0.0.1" : hostname;
|
|
89
|
+
console.log(`voidbase (data: ${dir}, hooks: ${process.env.VOIDBASE_HOOKS_DIR})`);
|
|
90
|
+
console.log(`Server started at http://${shown}:${port}\n├─ REST API: http://${shown}:${port}/api/\n└─ Dashboard: http://${shown}:${port}/_/`);
|
|
91
|
+
}
|
|
92
|
+
// bootstrap now (system collections, settings, superuser from env, pb_migrations) instead of on the first request
|
|
93
|
+
await fetch(`http://127.0.0.1:${port}/api/health`).catch(() => undefined);
|
|
94
|
+
await seedUser(port);
|
|
95
|
+
return { server, env, stop: () => { clearTimeout(timer); server.stop(true); } };
|
|
96
|
+
};
|
|
97
|
+
return { ...api, env, dir, start };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// VOIDBASE_USER_EMAIL / VOIDBASE_USER_PASSWORD: a test user in the `users` collection, created once (the starter's
|
|
101
|
+
// entrypoint used to do this with curl)
|
|
102
|
+
async function seedUser(port: number): Promise<void> {
|
|
103
|
+
const email = process.env.VOIDBASE_USER_EMAIL, password = process.env.VOIDBASE_USER_PASSWORD;
|
|
104
|
+
const su = process.env.VOIDBASE_SUPERUSER_EMAIL, suPass = process.env.VOIDBASE_SUPERUSER_PASSWORD;
|
|
105
|
+
if (!email || !password || !su || !suPass) return;
|
|
106
|
+
const base = `http://127.0.0.1:${port}`;
|
|
107
|
+
const json = (r: Response) => r.json() as Promise<Record<string, unknown>>;
|
|
108
|
+
const auth = await fetch(`${base}/api/collections/_superusers/auth-with-password`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ identity: su, password: suPass }) }).then(json).catch(() => null);
|
|
109
|
+
const token = auth?.token ? String(auth.token) : ""; if (!token) return;
|
|
110
|
+
const existing = await fetch(`${base}/api/collections/users/records?perPage=1&filter=${encodeURIComponent(`email = '${email.replace(/'/g, "\\'")}'`)}`, { headers: { authorization: token } }).then(json).catch(() => null);
|
|
111
|
+
if (!existing || Number(existing.totalItems ?? 0) > 0 || existing.status === 404) return;
|
|
112
|
+
const r = await fetch(`${base}/api/collections/users/records`, { method: "POST", headers: { "content-type": "application/json", authorization: token }, body: JSON.stringify({ email, password, passwordConfirm: password }) });
|
|
113
|
+
console.log(r.status === 200 ? `voidbase: created user ${email}` : `voidbase: could not create user ${email}: ${r.status}`);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function serve(opts: ServeOptions = {}): Promise<VoidbaseServer> {
|
|
117
|
+
return (await voidbase(opts)).start();
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// `pocketbase serve`-style flags: --http host:port --dir --hooksDir --migrationsDir --publicDir
|
|
121
|
+
export function parseServeArgs(argv: string[] = process.argv.slice(2)): ServeOptions {
|
|
122
|
+
const flags: Record<string, string> = {};
|
|
123
|
+
for (let i = 0; i < argv.length; i++) { const a = argv[i]!; if (a.startsWith("--")) { const [k, v] = a.slice(2).split("="); flags[k!] = v ?? (argv[i + 1] && !argv[i + 1]!.startsWith("--") ? argv[++i]! : "1"); } }
|
|
124
|
+
return { http: flags.http, dir: flags.dir, hooksDir: flags.hooksDir, migrationsDir: flags.migrationsDir, publicDir: flags.publicDir };
|
|
125
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/// <reference types="@cloudflare/workers-types" />
|
|
2
|
+
// R2Bucket on the filesystem: keys are paths under the root, object metadata lives in .meta/<key>.json.
|
|
3
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, join, relative } from "node:path";
|
|
5
|
+
interface Meta { contentType?: string; uploaded: string; etag: string; size: number }
|
|
6
|
+
interface Obj { key: string; size: number; uploaded: Date; etag: string; httpMetadata: { contentType?: string } }
|
|
7
|
+
export class FsBucket {
|
|
8
|
+
constructor(private root: string) { mkdirSync(join(root, ".meta"), { recursive: true }); }
|
|
9
|
+
private path(key: string) { return join(this.root, key); }
|
|
10
|
+
private metaPath(key: string) { return join(this.root, ".meta", `${key}.json`); }
|
|
11
|
+
private meta(key: string): Meta | null { try { return JSON.parse(readFileSync(this.metaPath(key), "utf8")) as Meta; } catch { return null; } }
|
|
12
|
+
private obj(key: string): Obj | null {
|
|
13
|
+
const p = this.path(key); if (!existsSync(p) || !statSync(p).isFile()) return null;
|
|
14
|
+
const m = this.meta(key); const st = statSync(p);
|
|
15
|
+
return { key, size: st.size, uploaded: new Date(m?.uploaded ?? st.mtimeMs), etag: m?.etag ?? String(st.mtimeMs), httpMetadata: { contentType: m?.contentType } };
|
|
16
|
+
}
|
|
17
|
+
async head(key: string) { return this.obj(key); }
|
|
18
|
+
async get(key: string, opts?: { range?: { offset: number; length: number } }) {
|
|
19
|
+
const o = this.obj(key); if (!o) return null;
|
|
20
|
+
const file = Bun.file(this.path(key));
|
|
21
|
+
const part = opts?.range ? file.slice(opts.range.offset, opts.range.offset + opts.range.length) : file;
|
|
22
|
+
return { ...o, body: part.stream(), arrayBuffer: () => part.arrayBuffer(), text: () => part.text(), json: <T>() => part.json() as Promise<T> };
|
|
23
|
+
}
|
|
24
|
+
async put(key: string, value: ArrayBuffer | Uint8Array | string | ReadableStream | Blob | null, opts?: { httpMetadata?: { contentType?: string } }) {
|
|
25
|
+
let bytes: Uint8Array;
|
|
26
|
+
if (value === null) bytes = new Uint8Array();
|
|
27
|
+
else if (typeof value === "string") bytes = new TextEncoder().encode(value);
|
|
28
|
+
else if (value instanceof Uint8Array) bytes = value;
|
|
29
|
+
else if (value instanceof ArrayBuffer) bytes = new Uint8Array(value);
|
|
30
|
+
else if (value instanceof Blob) bytes = new Uint8Array(await value.arrayBuffer());
|
|
31
|
+
else bytes = new Uint8Array(await new Response(value).arrayBuffer());
|
|
32
|
+
mkdirSync(dirname(this.path(key)), { recursive: true }); mkdirSync(dirname(this.metaPath(key)), { recursive: true });
|
|
33
|
+
writeFileSync(this.path(key), bytes);
|
|
34
|
+
const meta: Meta = { contentType: opts?.httpMetadata?.contentType, uploaded: new Date().toISOString(), etag: Bun.hash(bytes).toString(16), size: bytes.byteLength };
|
|
35
|
+
writeFileSync(this.metaPath(key), JSON.stringify(meta));
|
|
36
|
+
return { key, size: meta.size, uploaded: new Date(meta.uploaded), etag: meta.etag, httpMetadata: { contentType: meta.contentType } };
|
|
37
|
+
}
|
|
38
|
+
async delete(keys: string | string[]) {
|
|
39
|
+
for (const key of Array.isArray(keys) ? keys : [keys]) { rmSync(this.path(key), { force: true }); rmSync(this.metaPath(key), { force: true }); }
|
|
40
|
+
}
|
|
41
|
+
async list(opts: { prefix?: string; cursor?: string; limit?: number } = {}) {
|
|
42
|
+
const keys: string[] = [];
|
|
43
|
+
const walk = (dir: string) => { if (!existsSync(dir)) return; for (const e of readdirSync(dir, { withFileTypes: true })) { if (dir === this.root && e.name === ".meta") continue; const p = join(dir, e.name); if (e.isDirectory()) walk(p); else keys.push(relative(this.root, p).split("\\").join("/")); } };
|
|
44
|
+
walk(this.root);
|
|
45
|
+
const prefix = opts.prefix ?? ""; const limit = opts.limit ?? 1000;
|
|
46
|
+
const all = keys.filter((k) => k.startsWith(prefix) && (!opts.cursor || k > opts.cursor)).sort();
|
|
47
|
+
const page = all.slice(0, limit);
|
|
48
|
+
return { objects: page.map((k) => this.obj(k)!).filter(Boolean), truncated: all.length > limit, cursor: all.length > limit ? page.at(-1) : undefined, delimitedPrefixes: [] as string[] };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
export const fsBucket = (root: string) => new FsBucket(root) as unknown as R2Bucket;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// pb_hooks compiled at startup (the same transform the Vite plugin applies at build time) and imported as a module.
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join, resolve } from "node:path";
|
|
6
|
+
import { pathToFileURL } from "node:url";
|
|
7
|
+
import { compileHooksDir } from "../../../hooks-plugin";
|
|
8
|
+
const dir = resolve(process.env.VOIDBASE_HOOKS_DIR ?? "pb_hooks");
|
|
9
|
+
export async function loadCompiled(code: string, tag: string) {
|
|
10
|
+
const out = join(tmpdir(), "voidbase"); mkdirSync(out, { recursive: true });
|
|
11
|
+
const file = join(out, `${tag}-${createHash("sha256").update(code).digest("hex").slice(0, 16)}.mjs`);
|
|
12
|
+
writeFileSync(file, code);
|
|
13
|
+
return import(pathToFileURL(file).href);
|
|
14
|
+
}
|
|
15
|
+
const mod = await loadCompiled(compileHooksDir(dir), "hooks");
|
|
16
|
+
export const hooksDir: string = mod.hooksDir;
|
|
17
|
+
export const hooks: { name: string; run: (globals: Record<string, unknown>) => Promise<void> }[] = mod.hooks;
|
|
18
|
+
export const modules: Record<string, (globals: Record<string, unknown>) => Promise<unknown>> = mod.modules;
|
|
19
|
+
export const files: Record<string, string> = mod.files;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// One JSON line per call, like void/log, so `voidbase serve` logs stay greppable
|
|
2
|
+
const line = (level: string, message: string, fields?: Record<string, unknown>) => console[level === "error" ? "error" : level === "warn" ? "warn" : "log"](JSON.stringify({ level, message, time: new Date().toISOString(), ...(fields ?? {}) }));
|
|
3
|
+
export const logger = {
|
|
4
|
+
error: (message: string, fields?: Record<string, unknown>) => line("error", message, fields),
|
|
5
|
+
warn: (message: string, fields?: Record<string, unknown>) => line("warn", message, fields),
|
|
6
|
+
info: (message: string, fields?: Record<string, unknown>) => line("info", message, fields),
|
|
7
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { compileMigrationsDir } from "../../../hooks-plugin";
|
|
3
|
+
import { loadCompiled } from "./hooks";
|
|
4
|
+
const mod = await loadCompiled(compileMigrationsDir(resolve(process.env.VOIDBASE_MIGRATIONS_DIR ?? "pb_migrations")), "migrations");
|
|
5
|
+
export const migrations: { name: string; run: (globals: Record<string, unknown>) => Promise<void> }[] = mod.migrations;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { PhotonImage, SamplingFilter, crop, resize } from "@cf-wasm/photon/node";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// cloudflare:sockets `connect` on node:net / node:tls (Bun): readable/writable web streams and STARTTLS upgrade.
|
|
2
|
+
import net from "node:net";
|
|
3
|
+
import tls from "node:tls";
|
|
4
|
+
export interface Socket { readable: ReadableStream<Uint8Array>; writable: WritableStream<Uint8Array>; closed: Promise<void>; close(): void; startTls(): Socket }
|
|
5
|
+
function wrap(sock: net.Socket, host: string): Socket {
|
|
6
|
+
let ctrl: ReadableStreamDefaultController<Uint8Array> | null = null;
|
|
7
|
+
const onData = (d: Buffer) => { try { ctrl?.enqueue(new Uint8Array(d)); } catch { /* closed */ } };
|
|
8
|
+
const onEnd = () => { try { ctrl?.close(); } catch { /* closed */ } };
|
|
9
|
+
const onError = (e: Error) => { try { ctrl?.error(e); } catch { /* closed */ } };
|
|
10
|
+
const readable = new ReadableStream<Uint8Array>({ start(c) { ctrl = c; sock.on("data", onData); sock.on("end", onEnd); sock.on("error", onError); } });
|
|
11
|
+
const writable = new WritableStream<Uint8Array>({ write(chunk) { return new Promise<void>((res, rej) => sock.write(chunk, (e) => (e ? rej(e) : res()))); }, close() { sock.end(); } });
|
|
12
|
+
return {
|
|
13
|
+
readable, writable,
|
|
14
|
+
closed: new Promise<void>((r) => sock.once("close", () => r())),
|
|
15
|
+
close() { sock.destroy(); },
|
|
16
|
+
startTls() { sock.off("data", onData); sock.off("end", onEnd); sock.off("error", onError); return wrap(tls.connect({ socket: sock, servername: host }), host); },
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
export function connect(addr: { hostname: string; port: number }, opts: { secureTransport?: "on" | "off" | "starttls"; allowHalfOpen?: boolean } = {}): Socket {
|
|
20
|
+
const sock = opts.secureTransport === "on" ? tls.connect({ host: addr.hostname, port: addr.port, servername: addr.hostname }) : net.connect({ host: addr.hostname, port: addr.port });
|
|
21
|
+
return wrap(sock, addr.hostname);
|
|
22
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Server-sent events on a plain Response stream (the same surface as void/sse's eventStream).
|
|
2
|
+
export interface SSEStream { send(msg: { id?: string; event?: string; data: unknown }): Promise<void>; closed: Promise<void>; close(): void }
|
|
3
|
+
export function eventStream(handler: (stream: SSEStream) => Promise<void>, opts: { signal?: AbortSignal; keepAlive?: { intervalMs: number; comment?: string } } = {}): Response {
|
|
4
|
+
const enc = new TextEncoder();
|
|
5
|
+
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
|
|
6
|
+
let done = false; let resolveClosed = () => {};
|
|
7
|
+
const closed = new Promise<void>((r) => { resolveClosed = r; });
|
|
8
|
+
const finish = () => { if (done) return; done = true; if (keepAlive) clearInterval(keepAlive); resolveClosed(); try { controller?.close(); } catch { /* already closed */ } };
|
|
9
|
+
const write = (s: string) => { if (done) return; try { controller!.enqueue(enc.encode(s)); } catch { finish(); } };
|
|
10
|
+
const body = new ReadableStream<Uint8Array>({ start(c) { controller = c; }, cancel() { finish(); } });
|
|
11
|
+
const keepAlive = opts.keepAlive ? setInterval(() => write(`: ${opts.keepAlive!.comment ?? ""}\n\n`), opts.keepAlive.intervalMs) : null;
|
|
12
|
+
opts.signal?.addEventListener("abort", finish);
|
|
13
|
+
const stream: SSEStream = {
|
|
14
|
+
async send({ id, event, data }) {
|
|
15
|
+
let msg = ""; if (id) msg += `id: ${id}\n`; if (event) msg += `event: ${event}\n`;
|
|
16
|
+
for (const l of (typeof data === "string" ? data : JSON.stringify(data)).split("\n")) msg += `data: ${l}\n`;
|
|
17
|
+
write(msg + "\n");
|
|
18
|
+
},
|
|
19
|
+
closed, close: finish,
|
|
20
|
+
};
|
|
21
|
+
handler(stream).catch((e) => console.error("voidbase: sse handler failed", e)).finally(finish);
|
|
22
|
+
return new Response(body, { status: 200, headers: { "content-type": "text/event-stream; charset=utf-8", "cache-control": "no-store", connection: "keep-alive" } });
|
|
23
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { logger } from "void/log";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { migrations } from "virtual:voidbase-migrations";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { PhotonImage, SamplingFilter, crop, resize } from "@cf-wasm/photon";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { eventStream } from "void/sse";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// The programmatic API a project's main.ts receives (the counterpart of `pocketbase.New()` + app.OnServe() in Go):
|
|
2
|
+
// the same hook functions the JS hooks see ($app, $apis, routerAdd, cronAdd, on* events, Record, ...) plus the
|
|
3
|
+
// Hono router for raw routes. Registrations made here are committed with commit() before requests are served.
|
|
4
|
+
import type { Context, Hono } from "hono";
|
|
5
|
+
import { app } from "./app";
|
|
6
|
+
import { hookGlobals } from "./hooks";
|
|
7
|
+
import type { RequestEvent } from "./hooks/runtime";
|
|
8
|
+
import type { AppEnv } from "./types";
|
|
9
|
+
|
|
10
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
11
|
+
export type HookGlobals = Record<string, any>;
|
|
12
|
+
/** Hono-style handlers (c: Context) on any path, registered at any time; served after PocketBase's own routes. */
|
|
13
|
+
export interface LateRouter { get: LateRegister; post: LateRegister; patch: LateRegister; put: LateRegister; delete: LateRegister; all: LateRegister }
|
|
14
|
+
type LateRegister = (path: string, handler: (c: Context<AppEnv>) => Response | Promise<Response>) => void;
|
|
15
|
+
export interface VoidbaseApp {
|
|
16
|
+
/** app.router.get("/api/x", (c) => c.json(...)): Hono-style routes, usable before or after the JS hooks loaded */
|
|
17
|
+
router: LateRouter;
|
|
18
|
+
/** the Hono instance itself (routes added here must be added before the first request) */
|
|
19
|
+
hono: Hono<AppEnv>;
|
|
20
|
+
/** $app, $apis, $os, $security, routerAdd, routerUse, cronAdd, cronRemove, on* event registrations, Record, Collection, ... */
|
|
21
|
+
hooks: HookGlobals;
|
|
22
|
+
}
|
|
23
|
+
export function appApi(): VoidbaseApp {
|
|
24
|
+
const hooks = hookGlobals() as HookGlobals;
|
|
25
|
+
const late = (method: string): LateRegister => (path, handler) => hooks.routerAdd(method, path, (e: RequestEvent) => handler(e.c));
|
|
26
|
+
return { router: { get: late("GET"), post: late("POST"), patch: late("PATCH"), put: late("PUT"), delete: late("DELETE"), all: late("ALL") }, hono: app, hooks };
|
|
27
|
+
}
|