@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
package/package.json
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@voidbase-cloud/voidbase",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "PocketBase-compatible backend on Cloudflare Workers (D1, R2, Queues, Durable Objects) via Void, or a single Bun process",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/voidbase-cloud/voidbase.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/voidbase-cloud/voidbase#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/voidbase-cloud/voidbase/issues"
|
|
13
|
+
},
|
|
14
|
+
"private": false,
|
|
15
|
+
"type": "module",
|
|
16
|
+
"publishConfig": {
|
|
17
|
+
"access": "public"
|
|
18
|
+
},
|
|
19
|
+
"bin": {
|
|
20
|
+
"voidbase": "bin/voidbase.ts"
|
|
21
|
+
},
|
|
22
|
+
"exports": {
|
|
23
|
+
"./app": "./src/server/app.ts",
|
|
24
|
+
"./crons": "./src/server/crons.ts",
|
|
25
|
+
"./jobs": "./src/server/jobs.ts",
|
|
26
|
+
"./hub": "./src/server/hub.ts",
|
|
27
|
+
"./plugin": "./hooks-plugin.ts",
|
|
28
|
+
"./schema": "./db/schema.ts",
|
|
29
|
+
"./env": "./env.ts",
|
|
30
|
+
"./scripts/*": "./scripts/*",
|
|
31
|
+
"./package.json": "./package.json",
|
|
32
|
+
".": "./src/node/index.ts",
|
|
33
|
+
"./serve": "./src/node/serve.ts",
|
|
34
|
+
"./static": "./src/server/static.ts",
|
|
35
|
+
"./api": "./src/server/api.ts",
|
|
36
|
+
"./passkeys": "./src/server/webauthn.ts",
|
|
37
|
+
"./cloud": "./src/cloud/rest.ts",
|
|
38
|
+
"./bundle": "./src/node/bundle.ts"
|
|
39
|
+
},
|
|
40
|
+
"imports": {
|
|
41
|
+
"#platform/env": {
|
|
42
|
+
"workerd": "./src/platform/workers/env.ts",
|
|
43
|
+
"default": "./src/platform/node/env.ts"
|
|
44
|
+
},
|
|
45
|
+
"#platform/log": {
|
|
46
|
+
"workerd": "./src/platform/workers/log.ts",
|
|
47
|
+
"default": "./src/platform/node/log.ts"
|
|
48
|
+
},
|
|
49
|
+
"#platform/sse": {
|
|
50
|
+
"workerd": "./src/platform/workers/sse.ts",
|
|
51
|
+
"default": "./src/platform/node/sse.ts"
|
|
52
|
+
},
|
|
53
|
+
"#platform/sockets": {
|
|
54
|
+
"workerd": "./src/platform/workers/sockets.ts",
|
|
55
|
+
"default": "./src/platform/node/sockets.ts"
|
|
56
|
+
},
|
|
57
|
+
"#platform/hooks": {
|
|
58
|
+
"workerd": "./src/platform/workers/hooks.ts",
|
|
59
|
+
"default": "./src/platform/node/hooks.ts"
|
|
60
|
+
},
|
|
61
|
+
"#platform/migrations": {
|
|
62
|
+
"workerd": "./src/platform/workers/migrations.ts",
|
|
63
|
+
"default": "./src/platform/node/migrations.ts"
|
|
64
|
+
},
|
|
65
|
+
"#platform/photon": {
|
|
66
|
+
"workerd": "./src/platform/workers/photon.ts",
|
|
67
|
+
"default": "./src/platform/node/photon.ts"
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
"files": [
|
|
71
|
+
"bin",
|
|
72
|
+
"src",
|
|
73
|
+
"db",
|
|
74
|
+
"crons",
|
|
75
|
+
"queues",
|
|
76
|
+
"routes",
|
|
77
|
+
"scripts",
|
|
78
|
+
"docs",
|
|
79
|
+
"hooks-plugin.ts",
|
|
80
|
+
"vite.config.ts",
|
|
81
|
+
"void.json",
|
|
82
|
+
"env.ts",
|
|
83
|
+
"tsconfig.json",
|
|
84
|
+
".env.example",
|
|
85
|
+
"README.md",
|
|
86
|
+
"COMPAT.md",
|
|
87
|
+
"CHANGELOG.md",
|
|
88
|
+
"LICENSE",
|
|
89
|
+
"NOTICE",
|
|
90
|
+
"types",
|
|
91
|
+
"tsconfig.node.json"
|
|
92
|
+
],
|
|
93
|
+
"keywords": [
|
|
94
|
+
"pocketbase",
|
|
95
|
+
"cloudflare-workers",
|
|
96
|
+
"d1",
|
|
97
|
+
"r2",
|
|
98
|
+
"void",
|
|
99
|
+
"backend",
|
|
100
|
+
"baas"
|
|
101
|
+
],
|
|
102
|
+
"engines": {
|
|
103
|
+
"bun": ">=1.2"
|
|
104
|
+
},
|
|
105
|
+
"scripts": {
|
|
106
|
+
"dev": "vp dev",
|
|
107
|
+
"build": "vp build",
|
|
108
|
+
"preview": "vp preview",
|
|
109
|
+
"panel:sync": "bun scripts/sync-panel.ts",
|
|
110
|
+
"surface": "bun surface/render.ts",
|
|
111
|
+
"test": "bun test",
|
|
112
|
+
"app:sync": "bun scripts/sync-app.ts",
|
|
113
|
+
"check": "void prepare && tsc -p tsconfig.json --noEmit && tsc -p tsconfig.node.json --noEmit",
|
|
114
|
+
"pack:check": "npm pack --dry-run",
|
|
115
|
+
"prepublishOnly": "bun run check && bun test"
|
|
116
|
+
},
|
|
117
|
+
"dependencies": {
|
|
118
|
+
"@cf-wasm/photon": "^0.4.0",
|
|
119
|
+
"@simplewebauthn/server": "^14.0.1",
|
|
120
|
+
"bcryptjs": "^3.0.2",
|
|
121
|
+
"fflate": "^0.8.3",
|
|
122
|
+
"hono": "^4.11.9",
|
|
123
|
+
"typescript": "^5.9.3",
|
|
124
|
+
"void": "^0.10.13",
|
|
125
|
+
"vite": "^8.0.10",
|
|
126
|
+
"vite-plus": "^0.1.21",
|
|
127
|
+
"@cloudflare/workers-types": "^4.20250903.0"
|
|
128
|
+
},
|
|
129
|
+
"devDependencies": {
|
|
130
|
+
"@types/bun": "^1.4.1",
|
|
131
|
+
"playwright": "^1.63.0",
|
|
132
|
+
"pocketbase": "0.28.0"
|
|
133
|
+
}
|
|
134
|
+
}
|
package/queues/jobs.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Cloudflare Queue consumer for voidbase's background jobs: outbound mail and automatic backups.
|
|
2
|
+
// Its presence gives the Worker the QUEUE_JOBS producer binding; without this file every job runs
|
|
3
|
+
// inline in the request (the Bun runtime always does). Retries: up to maxRetries with backoff, then dropped and alerted.
|
|
4
|
+
import { defineQueue } from "void";
|
|
5
|
+
import "../src/server/app"; // registers the job handlers (mail, backups, thumbnails) and the hooks
|
|
6
|
+
import { consumeJobs, type Job } from "../src/server/jobs";
|
|
7
|
+
|
|
8
|
+
export const maxBatchSize = 10;
|
|
9
|
+
export const maxBatchTimeout = 1; // seconds: mail should leave promptly
|
|
10
|
+
export const maxRetries = 5;
|
|
11
|
+
export const retryDelay = 30;
|
|
12
|
+
|
|
13
|
+
export default defineQueue<Job>(async (batch, env) => { await consumeJobs(batch as never, env as never, maxRetries); });
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Every /api/* request is handled by the voidbase Hono app (PocketBase wire protocol).
|
|
2
|
+
import { defineHandler } from "void";
|
|
3
|
+
import { app } from "../../src/server/app";
|
|
4
|
+
import { appApi } from "../../src/server/api";
|
|
5
|
+
import { mountWebAuthn } from "../../src/server/webauthn";
|
|
6
|
+
|
|
7
|
+
// this checkout serves the pocketbase-sveltekit-starter, whose backend registers passkey routes (pb/webauthn)
|
|
8
|
+
mountWebAuthn(appApi().router);
|
|
9
|
+
|
|
10
|
+
const handle = defineHandler((c) =>
|
|
11
|
+
app.fetch(c.req.raw, c.env, (c as unknown as { executionCtx?: ExecutionContext }).executionCtx),
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
export const GET = handle;
|
|
15
|
+
export const POST = handle;
|
|
16
|
+
export const PATCH = handle;
|
|
17
|
+
export const PUT = handle;
|
|
18
|
+
export const DELETE = handle;
|
|
19
|
+
export const OPTIONS = handle;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Realtime fan-out check: opens N SSE clients, subscribes each to a collection, creates one record and measures how
|
|
2
|
+
// many clients receive the event and how long it takes (the poll loop targets about one second).
|
|
3
|
+
// bun scripts/bench-realtime.ts [url=http://127.0.0.1:5180] [clients=100]
|
|
4
|
+
const url = (process.argv[2] ?? "http://127.0.0.1:5180").replace(/\/$/, ""); const N = Number(process.argv[3] ?? 100);
|
|
5
|
+
const su = await fetch(`${url}/api/collections/_superusers/auth-with-password`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ identity: process.env.VOIDBASE_SUPERUSER_EMAIL ?? "admin@example.com", password: process.env.VOIDBASE_SUPERUSER_PASSWORD ?? "changeme123" }) }).then((r) => r.json()) as { token: string };
|
|
6
|
+
const H = { authorization: su.token, "content-type": "application/json" };
|
|
7
|
+
await fetch(`${url}/api/collections/ks_rt`, { method: "DELETE", headers: H });
|
|
8
|
+
await fetch(`${url}/api/collections`, { method: "POST", headers: H, body: JSON.stringify({ name: "ks_rt", type: "base", listRule: "", viewRule: "", fields: [{ name: "title", type: "text" }] }) });
|
|
9
|
+
interface Client { id: string; ac: AbortController; received: number[] }
|
|
10
|
+
const clients: Client[] = []; let sent = 0;
|
|
11
|
+
async function open(): Promise<Client> {
|
|
12
|
+
const ac = new AbortController(); const res = await fetch(`${url}/api/realtime`, { headers: { accept: "text/event-stream" }, signal: ac.signal });
|
|
13
|
+
const reader = res.body!.getReader(); const dec = new TextDecoder(); let buf = "";
|
|
14
|
+
const client: Client = { id: "", ac, received: [] };
|
|
15
|
+
const connected = new Promise<void>((resolve) => {
|
|
16
|
+
(async () => {
|
|
17
|
+
for (;;) {
|
|
18
|
+
const { value, done } = await reader.read().catch(() => ({ value: undefined, done: true })); if (done) break;
|
|
19
|
+
buf += dec.decode(value, { stream: true }); let i: number;
|
|
20
|
+
while ((i = buf.indexOf("\n\n")) >= 0) {
|
|
21
|
+
const chunk = buf.slice(0, i); buf = buf.slice(i + 2); let ev = "message", data = "";
|
|
22
|
+
for (const line of chunk.split("\n")) { if (line.startsWith("event:")) ev = line.slice(6).trim(); else if (line.startsWith("data:")) data += line.slice(5).trim(); }
|
|
23
|
+
if (ev === "PB_CONNECT") { client.id = (JSON.parse(data) as { clientId: string }).clientId; resolve(); }
|
|
24
|
+
else if (ev === "ks_rt/*") client.received.push(performance.now() - sent);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
})();
|
|
28
|
+
});
|
|
29
|
+
await connected;
|
|
30
|
+
const sub = await fetch(`${url}/api/realtime`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ clientId: client.id, subscriptions: ["ks_rt/*"] }) });
|
|
31
|
+
if (sub.status !== 204) throw new Error(`subscribe failed: ${sub.status}`);
|
|
32
|
+
return client;
|
|
33
|
+
}
|
|
34
|
+
const t0 = performance.now();
|
|
35
|
+
const BATCH = 25;
|
|
36
|
+
for (let i = 0; i < N; i += BATCH) clients.push(...(await Promise.all(Array.from({ length: Math.min(BATCH, N - i) }, open))));
|
|
37
|
+
const openMs = performance.now() - t0;
|
|
38
|
+
sent = performance.now();
|
|
39
|
+
await fetch(`${url}/api/collections/ks_rt/records`, { method: "POST", headers: H, body: JSON.stringify({ title: "ping" }) });
|
|
40
|
+
await new Promise((r) => setTimeout(r, 5000));
|
|
41
|
+
const got = clients.filter((c) => c.received.length > 0); const lat = got.map((c) => c.received[0]!).sort((a, b) => a - b);
|
|
42
|
+
const pct = (p: number) => lat.length ? lat[Math.min(lat.length - 1, Math.floor(lat.length * p))]!.toFixed(0) : "-";
|
|
43
|
+
console.log(`clients: ${N} opened+subscribed in ${(openMs / 1000).toFixed(1)}s; event received by ${got.length}/${N}; delivery latency p50 ${pct(0.5)} ms, p95 ${pct(0.95)} ms, max ${pct(1)} ms`);
|
|
44
|
+
for (const c of clients) c.ac.abort();
|
|
45
|
+
await fetch(`${url}/api/collections/ks_rt`, { method: "DELETE", headers: H });
|
|
46
|
+
process.exit(0);
|
package/scripts/bench.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// Latency and throughput baseline per endpoint against a running voidbase (or PocketBase): sequential p50/p95 and a
|
|
2
|
+
// concurrent burst. Numbers from a local dev server (miniflare) are not production numbers; see docs/perf.md.
|
|
3
|
+
// bun scripts/bench.ts [url=http://127.0.0.1:5180] [--n 30] [--concurrency 10]
|
|
4
|
+
const args = process.argv.slice(2); const url = (args.find((a) => !a.startsWith("--")) ?? "http://127.0.0.1:5180").replace(/\/$/, "");
|
|
5
|
+
const opt = (k: string, d: number) => { const i = args.indexOf(`--${k}`); return i >= 0 ? Number(args[i + 1]) : d; };
|
|
6
|
+
const N = opt("n", 30), C = opt("concurrency", 10);
|
|
7
|
+
const su = await fetch(`${url}/api/collections/_superusers/auth-with-password`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ identity: process.env.VOIDBASE_SUPERUSER_EMAIL ?? "admin@example.com", password: process.env.VOIDBASE_SUPERUSER_PASSWORD ?? "changeme123" }) }).then((r) => r.json()) as { token: string };
|
|
8
|
+
const H = { authorization: su.token, "content-type": "application/json" };
|
|
9
|
+
await fetch(`${url}/api/collections/ks_bench`, { method: "DELETE", headers: H });
|
|
10
|
+
await fetch(`${url}/api/collections`, { method: "POST", headers: H, body: JSON.stringify({ name: "ks_bench", type: "base", listRule: "", viewRule: "", fields: [{ name: "title", type: "text" }, { name: "n", type: "number" }, { name: "pic", type: "file", maxSelect: 1, thumbs: ["100x100"] }] }) });
|
|
11
|
+
const png = Uint8Array.from(atob("iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAEUlEQVR4nGP4z8AAQv8ZYAwAQ84H+VjtZqAAAAAASUVORK5CYII="), (c) => c.charCodeAt(0));
|
|
12
|
+
const fd = new FormData(); fd.append("title", "seed"); fd.append("pic", new Blob([png], { type: "image/png" }), "dot.png");
|
|
13
|
+
const seeded = (await fetch(`${url}/api/collections/ks_bench/records`, { method: "POST", headers: { authorization: su.token }, body: fd }).then((r) => r.json())) as { id: string; pic: string };
|
|
14
|
+
for (let i = 0; i < 50; i++) await fetch(`${url}/api/collections/ks_bench/records`, { method: "POST", headers: H, body: JSON.stringify({ title: `row ${i}`, n: i }) });
|
|
15
|
+
const created: string[] = [];
|
|
16
|
+
const cases: { name: string; run: () => Promise<Response> }[] = [
|
|
17
|
+
{ name: "GET /api/health", run: () => fetch(`${url}/api/health`) },
|
|
18
|
+
{ name: "GET records list (30)", run: () => fetch(`${url}/api/collections/ks_bench/records?perPage=30`) },
|
|
19
|
+
{ name: "GET records list + filter + sort", run: () => fetch(`${url}/api/collections/ks_bench/records?perPage=30&filter=${encodeURIComponent("n > 10 && title ~ 'row'")}&sort=-n`) },
|
|
20
|
+
{ name: "GET record view", run: () => fetch(`${url}/api/collections/ks_bench/records/${seeded.id}`) },
|
|
21
|
+
{ name: "POST record create", run: async () => { const r = await fetch(`${url}/api/collections/ks_bench/records`, { method: "POST", headers: H, body: JSON.stringify({ title: "bench", n: 1 }) }); created.push(String(((await r.clone().json()) as { id: string }).id)); return r; } },
|
|
22
|
+
{ name: "PATCH record update", run: () => fetch(`${url}/api/collections/ks_bench/records/${seeded.id}`, { method: "PATCH", headers: H, body: JSON.stringify({ n: Math.random() }) }) },
|
|
23
|
+
{ name: "POST auth-with-password", run: () => fetch(`${url}/api/collections/_superusers/auth-with-password`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ identity: "admin@example.com", password: "changeme123" }) }) },
|
|
24
|
+
{ name: "GET file", run: () => fetch(`${url}/api/files/ks_bench/${seeded.id}/${seeded.pic}`) },
|
|
25
|
+
{ name: "GET thumb 100x100 (cached after first)", run: () => fetch(`${url}/api/files/ks_bench/${seeded.id}/${seeded.pic}?thumb=100x100`) },
|
|
26
|
+
{ name: "GET collections list (superuser)", run: () => fetch(`${url}/api/collections`, { headers: H }) },
|
|
27
|
+
];
|
|
28
|
+
const pct = (xs: number[], p: number) => xs.slice().sort((a, b) => a - b)[Math.min(xs.length - 1, Math.floor(xs.length * p))]!;
|
|
29
|
+
console.log(`voidbase bench against ${url}: ${N} sequential requests, then ${C}x${N} concurrent, per endpoint\n`);
|
|
30
|
+
console.log("| endpoint | p50 ms | p95 ms | max ms | concurrent req/s | errors |\n| --- | ---: | ---: | ---: | ---: | ---: |");
|
|
31
|
+
for (const c of cases) {
|
|
32
|
+
const lat: number[] = []; let errors = 0;
|
|
33
|
+
for (let i = 0; i < N; i++) { const t = performance.now(); const r = await c.run(); await r.arrayBuffer(); lat.push(performance.now() - t); if (r.status >= 400) errors++; }
|
|
34
|
+
const t0 = performance.now();
|
|
35
|
+
await Promise.all(Array.from({ length: C }, async () => { for (let i = 0; i < N; i++) { const r = await c.run(); await r.arrayBuffer(); if (r.status >= 400) errors++; } }));
|
|
36
|
+
const rps = (C * N) / ((performance.now() - t0) / 1000);
|
|
37
|
+
console.log(`| ${c.name} | ${pct(lat, 0.5).toFixed(1)} | ${pct(lat, 0.95).toFixed(1)} | ${Math.max(...lat).toFixed(1)} | ${rps.toFixed(0)} | ${errors} |`);
|
|
38
|
+
}
|
|
39
|
+
await fetch(`${url}/api/collections/ks_bench`, { method: "DELETE", headers: H });
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Runs every differential and browser suite against a reference PocketBase and a voidbase, printing one line per
|
|
3
|
+
# suite and failing if any suite fails. Needs test/smtp-sink.ts (2525/2526), test/mock-oidc.ts (5190) and test/s3-mock.ts (5195) running.
|
|
4
|
+
# scripts/ci-suites.sh [pb=http://127.0.0.1:8090] [vb=http://127.0.0.1:5180] [suites...]
|
|
5
|
+
set -u
|
|
6
|
+
cd "$(dirname "$0")/.."
|
|
7
|
+
PB="${1:-http://127.0.0.1:8090}"; VB="${2:-http://127.0.0.1:5180}"; shift 2 2>/dev/null || true
|
|
8
|
+
LOGS="${CI_LOGS:-.void/ci-logs}"; mkdir -p "$LOGS"
|
|
9
|
+
POSITIONAL="auth-flows backups batch cascade filter-corpus filters-extra hardening logs-crons manage-rule oauth2 otp-mfa protected-files providers rules s3 security settings sql thumbs views"
|
|
10
|
+
FLAGGED="compare records realtime collections"
|
|
11
|
+
fail=0; run() { local name="$1"; shift; if timeout 900 "$@" > "$LOGS/$name.log" 2>&1; then echo "PASS $name $(tail -1 "$LOGS/$name.log" | cut -c1-90)"; else fail=$((fail+1)); echo "FAIL $name (see $LOGS/$name.log)"; grep -E "^FAIL|Error|error:" "$LOGS/$name.log" | head -5 | sed 's/^/ /'; fi; }
|
|
12
|
+
SEL="${*:-all}"
|
|
13
|
+
want() { [ "$SEL" = "all" ] || [[ " $SEL " == *" $1 "* ]]; }
|
|
14
|
+
for s in $POSITIONAL; do want "$s" && run "$s" bun "test/conformance/$s.ts" "$PB" "$VB"; done
|
|
15
|
+
for s in $FLAGGED; do want "$s" && run "$s" bun "test/conformance/$s.ts" --pb "$PB" --vb "$VB"; done
|
|
16
|
+
want sdk-suite && run sdk-suite bun test/sdk-suite.ts "$PB" "$VB"
|
|
17
|
+
want unit && run unit bun test
|
|
18
|
+
want cloud-rest && run cloud-rest bun test/cloud-rest.ts
|
|
19
|
+
if [ "${CI_BROWSER:-1}" = "1" ]; then
|
|
20
|
+
want panel-smoke && run panel-smoke bun test/panel-smoke.ts "$VB" "$LOGS/panel.png"
|
|
21
|
+
want panel-collections && run panel-collections bun test/panel-collections.ts "$VB" "$LOGS/panel-collections.png"
|
|
22
|
+
want panel-records && run panel-records bun test/panel-records.ts "$VB" "$LOGS/panel-records.png"
|
|
23
|
+
want panel-admin && run panel-admin bun test/panel-admin.ts "$VB"
|
|
24
|
+
want panel-login && run panel-login bun test/panel-login.ts "$PB" "$VB"
|
|
25
|
+
fi
|
|
26
|
+
echo; [ "$fail" = 0 ] && echo "ALL SUITES PASSED" || echo "$fail SUITE(S) FAILED"
|
|
27
|
+
exit $fail
|
package/scripts/dev.sh
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Start/stop the Vite+Void dev server in the background with a pidfile (used by the test harnesses).
|
|
3
|
+
# scripts/dev.sh start [port] scripts/dev.sh stop scripts/dev.sh status scripts/dev.sh log
|
|
4
|
+
set -u
|
|
5
|
+
cd "$(dirname "$0")/.."
|
|
6
|
+
PORT="${2:-${PORT:-5180}}"
|
|
7
|
+
PIDFILE=".void/dev.pid"; LOG=".void/dev.log"
|
|
8
|
+
mkdir -p .void
|
|
9
|
+
running() { [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; }
|
|
10
|
+
case "${1:-status}" in
|
|
11
|
+
start)
|
|
12
|
+
if running; then echo "already running (pid $(cat "$PIDFILE"))"; exit 0; fi
|
|
13
|
+
setsid nohup ./node_modules/.bin/vp dev --port "$PORT" --host 127.0.0.1 > "$LOG" 2>&1 < /dev/null &
|
|
14
|
+
echo $! > "$PIDFILE"
|
|
15
|
+
echo "started pid $! on http://127.0.0.1:$PORT (log: $LOG)"
|
|
16
|
+
curl --retry 90 --retry-delay 1 --retry-all-errors -s -o /dev/null -w "ready: HTTP %{http_code} for /api/health\n" "http://127.0.0.1:$PORT/api/health" ;;
|
|
17
|
+
stop)
|
|
18
|
+
if running; then
|
|
19
|
+
pid="$(cat "$PIDFILE")"
|
|
20
|
+
# setsid made $pid a group leader: signal the whole group so vite and workerd children die too
|
|
21
|
+
kill -TERM -- "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null
|
|
22
|
+
for _ in $(seq 1 50); do kill -0 "$pid" 2>/dev/null || break; sleep 0.2; done
|
|
23
|
+
kill -0 "$pid" 2>/dev/null && { kill -KILL -- "-$pid" 2>/dev/null; sleep 0.5; }
|
|
24
|
+
echo "stopped"
|
|
25
|
+
else echo "not running"; fi
|
|
26
|
+
rm -f "$PIDFILE" ;;
|
|
27
|
+
status) if running; then echo "running (pid $(cat "$PIDFILE"))"; else echo "not running"; fi ;;
|
|
28
|
+
log) tail -n "${2:-40}" "$LOG" ;;
|
|
29
|
+
esac
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Exports everything from a running voidbase into a directory you can take elsewhere:
|
|
2
|
+
// <out>/data.db SQLite file with the same tables and columns as PocketBase (user collections, _collections,
|
|
3
|
+
// _params, _externalAuths, _authOrigins, _mfas, _otps), rows copied verbatim (password hashes included)
|
|
4
|
+
// <out>/collections.json the collections in PocketBase's import format (Settings > Import collections)
|
|
5
|
+
// <out>/storage/ every uploaded file under {collectionId}/{recordId}/{filename}
|
|
6
|
+
// Reads through the superuser API only (POST /api/sql, GET /api/files), so it works against a deployed instance.
|
|
7
|
+
// bun scripts/export.ts <url> <outDir> [superuserEmail] [superuserPassword]
|
|
8
|
+
import { Database } from "bun:sqlite";
|
|
9
|
+
import { mkdirSync, writeFileSync, existsSync, rmSync } from "node:fs";
|
|
10
|
+
export async function exportAll(url: string, out: string, email: string, password: string): Promise<{ collections: number; rows: number; files: number }> {
|
|
11
|
+
const base = url.replace(/\/$/, "");
|
|
12
|
+
const auth = await fetch(`${base}/api/collections/_superusers/auth-with-password`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ identity: email, password }) });
|
|
13
|
+
if (auth.status !== 200) throw new Error(`superuser login failed: ${auth.status} ${await auth.text()}`);
|
|
14
|
+
const token = ((await auth.json()) as { token: string }).token;
|
|
15
|
+
const H = { authorization: token, "content-type": "application/json" };
|
|
16
|
+
async function sql(query: string): Promise<{ columns: { name: string; type: string }[]; rows: (string | null)[][] }> {
|
|
17
|
+
const r = await fetch(`${base}/api/sql`, { method: "POST", headers: H, body: JSON.stringify({ query }) });
|
|
18
|
+
if (r.status !== 200) throw new Error(`sql failed (${r.status}): ${query.slice(0, 80)} -> ${await r.text()}`);
|
|
19
|
+
return (await r.json()) as { columns: { name: string; type: string }[]; rows: (string | null)[][] };
|
|
20
|
+
}
|
|
21
|
+
const collections = ((await fetch(`${base}/api/collections?perPage=500&sort=+created`, { headers: H }).then((r) => r.json())) as { items: Record<string, unknown>[] }).items;
|
|
22
|
+
if (existsSync(out)) rmSync(out, { recursive: true, force: true });
|
|
23
|
+
mkdirSync(`${out}/storage`, { recursive: true });
|
|
24
|
+
writeFileSync(`${out}/collections.json`, JSON.stringify(collections, null, 2));
|
|
25
|
+
const db = new Database(`${out}/data.db`, { create: true });
|
|
26
|
+
// system auth collections (_externalAuths, _authOrigins, _mfas, _otps) are collections too, so dedupe
|
|
27
|
+
const tables = [...new Set([...collections.filter((c) => c.type !== "view").map((c) => String(c.name)), "_collections", "_params", "_externalAuths", "_authOrigins", "_mfas", "_otps"])];
|
|
28
|
+
let files = 0, rows = 0;
|
|
29
|
+
for (const table of tables) {
|
|
30
|
+
const info = await sql(`PRAGMA table_info("${table}")`);
|
|
31
|
+
if (!info.rows.length) { console.warn(`skip ${table}: no such table`); continue; }
|
|
32
|
+
const cols = info.rows.map((r) => ({ name: String(r[1]), type: String(r[2] ?? ""), pk: r[5] === "1" }));
|
|
33
|
+
db.run(`CREATE TABLE "${table}" (${cols.map((c) => `"${c.name}" ${c.type}${c.pk ? " PRIMARY KEY" : ""}`).join(", ")})`);
|
|
34
|
+
const insert = db.prepare(`INSERT INTO "${table}" (${cols.map((c) => `"${c.name}"`).join(", ")}) VALUES (${cols.map(() => "?").join(", ")})`);
|
|
35
|
+
const coll = collections.find((c) => c.name === table);
|
|
36
|
+
const fileFields = ((coll?.fields as { name: string; type: string }[] | undefined) ?? []).filter((f) => f.type === "file").map((f) => f.name);
|
|
37
|
+
let after = 0;
|
|
38
|
+
for (;;) {
|
|
39
|
+
const page = await sql(`SELECT rowid AS __rowid, ${cols.map((c) => `"${c.name}"`).join(", ")} FROM "${table}" WHERE rowid > ${after} ORDER BY rowid LIMIT 1000`);
|
|
40
|
+
if (!page.rows.length) break;
|
|
41
|
+
db.transaction(() => { for (const r of page.rows) insert.run(...r.slice(1)); })();
|
|
42
|
+
rows += page.rows.length; after = Number(page.rows.at(-1)![0]);
|
|
43
|
+
for (const r of page.rows) {
|
|
44
|
+
const rec = Object.fromEntries(cols.map((c, i) => [c.name, r[i + 1]]));
|
|
45
|
+
for (const f of fileFields) {
|
|
46
|
+
let names: string[] = []; const raw = rec[f]; if (!raw) continue;
|
|
47
|
+
try { const parsed = JSON.parse(String(raw)); names = Array.isArray(parsed) ? parsed.map(String) : [String(parsed)]; } catch { names = [String(raw)]; }
|
|
48
|
+
for (const name of names) {
|
|
49
|
+
const res = await fetch(`${base}/api/files/${coll!.id}/${rec.id}/${encodeURIComponent(name)}?download=1`, { headers: { authorization: token } });
|
|
50
|
+
if (res.status !== 200) { console.warn(`file ${table}/${rec.id}/${name}: HTTP ${res.status}`); continue; }
|
|
51
|
+
mkdirSync(`${out}/storage/${coll!.id}/${rec.id}`, { recursive: true });
|
|
52
|
+
writeFileSync(`${out}/storage/${coll!.id}/${rec.id}/${name}`, new Uint8Array(await res.arrayBuffer())); files++;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (page.rows.length < 1000) break;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
for (const c of collections.filter((c) => c.type === "view")) { try { db.run(`CREATE VIEW "${c.name}" AS ${String(c.viewQuery ?? "")}`); } catch (e) { console.warn(`view ${c.name} not recreated: ${e instanceof Error ? e.message : e}`); } }
|
|
60
|
+
db.close();
|
|
61
|
+
writeFileSync(`${out}/README.txt`, `voidbase export from ${base} at ${new Date().toISOString()}\n\ndata.db SQLite: one table per collection with PocketBase's column layout, plus _collections/_params/_externalAuths/_authOrigins/_mfas/_otps\ncollections.json PocketBase collections import format (Settings > Import collections in any PocketBase or voidbase)\nstorage/ uploaded files as {collectionId}/{recordId}/{filename}\n\nTo move to PocketBase: import collections.json, then load rows from data.db (same table and column names) and copy storage/ into pb_data/storage/.\n`);
|
|
62
|
+
return { collections: collections.length, rows, files };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (import.meta.main) {
|
|
66
|
+
const [url, out, email = process.env.VOIDBASE_SUPERUSER_EMAIL ?? "admin@example.com", password = process.env.VOIDBASE_SUPERUSER_PASSWORD ?? "changeme123"] = process.argv.slice(2);
|
|
67
|
+
if (!url || !out) { console.error("usage: bun scripts/export.ts <url> <outDir> [email] [password]"); process.exit(1); }
|
|
68
|
+
const r = await exportAll(url, out, email, password);
|
|
69
|
+
console.log(`exported ${r.collections} collections, ${r.rows} rows, ${r.files} files to ${out}`);
|
|
70
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Creates the app user the differential suites and the starter expect (user@example.com / changeme123) on a
|
|
3
|
+
# server, idempotently. Works against voidbase and PocketBase alike.
|
|
4
|
+
# scripts/seed-app-user.sh [url=http://127.0.0.1:5180]
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
URL="${1:-http://127.0.0.1:5180}"
|
|
7
|
+
SU_EMAIL="${VOIDBASE_SUPERUSER_EMAIL:-admin@example.com}"; SU_PASSWORD="${VOIDBASE_SUPERUSER_PASSWORD:-changeme123}"
|
|
8
|
+
USER_EMAIL="${REFERENCE_USER_EMAIL:-user@example.com}"; USER_PASSWORD="${REFERENCE_USER_PASSWORD:-changeme123}"
|
|
9
|
+
TOKEN=$(curl -s -X POST "$URL/api/collections/_superusers/auth-with-password" -H "content-type: application/json" -d "{\"identity\":\"$SU_EMAIL\",\"password\":\"$SU_PASSWORD\"}" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
|
|
10
|
+
[ -n "$TOKEN" ] || { echo "superuser login failed at $URL"; exit 1; }
|
|
11
|
+
existing=$(curl -s "$URL/api/collections/users/records?filter=email%3D%27$USER_EMAIL%27" -H "authorization: $TOKEN" | sed -n 's/.*"totalItems":\([0-9]*\).*/\1/p')
|
|
12
|
+
if [ "${existing:-0}" = "0" ]; then
|
|
13
|
+
curl -s -o /dev/null -w "user $USER_EMAIL at $URL: HTTP %{http_code}\n" -X POST "$URL/api/collections/users/records" -H "content-type: application/json" -H "authorization: $TOKEN" -d "{\"email\":\"$USER_EMAIL\",\"password\":\"$USER_PASSWORD\",\"passwordConfirm\":\"$USER_PASSWORD\"}"
|
|
14
|
+
else echo "user $USER_EMAIL already present at $URL"; fi
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Seeds a local Void state directory's D1 file with the system tables from db/migrations/*.sql, for dev/preview
|
|
2
|
+
// servers that use `voidPlugin({ persistTo })` (void db migrate only knows the default .void state).
|
|
3
|
+
// bun scripts/seed-d1.ts <persistDir> e.g. .void-ci
|
|
4
|
+
import { Database } from "bun:sqlite";
|
|
5
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync } from "node:fs";
|
|
6
|
+
const target = process.argv[2]; if (!target) { console.error("usage: bun scripts/seed-d1.ts <persistDir>"); process.exit(1); }
|
|
7
|
+
// miniflare names the database file deterministically from the binding; reuse the default state's name when present
|
|
8
|
+
const defaultDir = ".void/v3/d1/miniflare-D1DatabaseObject";
|
|
9
|
+
const fileName = (existsSync(defaultDir) ? readdirSync(defaultDir).find((f) => f.endsWith(".sqlite") && f !== "metadata.sqlite") : undefined) ?? "6a4e4d6dbf1fb3c3d2b0b0b6b2a2d4e7c1f0a9b8c7d6e5f4a3b2c1d0e9f8a7b6.sqlite";
|
|
10
|
+
const dir = `${target}/v3/d1/miniflare-D1DatabaseObject`; mkdirSync(dir, { recursive: true });
|
|
11
|
+
const db = new Database(`${dir}/${fileName}`, { create: true });
|
|
12
|
+
let applied = 0;
|
|
13
|
+
for (const f of readdirSync("db/migrations").filter((f) => f.endsWith(".sql")).sort()) {
|
|
14
|
+
for (const statement of readFileSync(`db/migrations/${f}`, "utf8").split("--> statement-breakpoint")) if (statement.trim()) { try { db.run(statement); applied++; } catch (e) { if (!String(e).includes("already exists")) throw e; } }
|
|
15
|
+
}
|
|
16
|
+
db.close();
|
|
17
|
+
console.log(`seeded ${dir}/${fileName} with ${applied} statements`);
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Prepares and starts a reference PocketBase for the differential suites: downloads the release binary, runs the
|
|
3
|
+
# starter's pb_migrations and pb_hooks, upserts the superuser and creates the app user the suites expect.
|
|
4
|
+
# scripts/seed-reference.sh <dir> [port=8090] [version=0.39.11] [starter=../pocketbase-sveltekit-starter]
|
|
5
|
+
# scripts/seed-reference.sh stop <dir>
|
|
6
|
+
set -euo pipefail
|
|
7
|
+
if [ "${1:-}" = "stop" ]; then d="$2"; [ -f "$d/pb.pid" ] && { kill -TERM -- "-$(cat "$d/pb.pid")" 2>/dev/null || kill -TERM "$(cat "$d/pb.pid")" 2>/dev/null || true; rm -f "$d/pb.pid"; echo "stopped"; }; exit 0; fi
|
|
8
|
+
DIR="${1:?dir}"; PORT="${2:-8090}"; VERSION="${3:-0.39.11}"; STARTER="${4:-../pocketbase-sveltekit-starter}"
|
|
9
|
+
SU_EMAIL="${VOIDBASE_SUPERUSER_EMAIL:-admin@example.com}"; SU_PASSWORD="${VOIDBASE_SUPERUSER_PASSWORD:-changeme123}"
|
|
10
|
+
USER_EMAIL="${REFERENCE_USER_EMAIL:-user@example.com}"; USER_PASSWORD="${REFERENCE_USER_PASSWORD:-changeme123}"
|
|
11
|
+
mkdir -p "$DIR"; STARTER="$(cd "$STARTER" && pwd)"
|
|
12
|
+
if [ ! -x "$DIR/pocketbase" ] || [ "$("$DIR/pocketbase" --version 2>/dev/null)" != "pocketbase version $VERSION" ]; then
|
|
13
|
+
arch="linux_amd64"; case "$(uname -m)" in aarch64|arm64) arch="linux_arm64";; esac
|
|
14
|
+
echo "downloading pocketbase $VERSION ($arch)"
|
|
15
|
+
curl -sSL "https://github.com/pocketbase/pocketbase/releases/download/v${VERSION}/pocketbase_${VERSION}_${arch}.zip" -o "$DIR/pb.zip"
|
|
16
|
+
(cd "$DIR" && unzip -oq pb.zip pocketbase && rm pb.zip)
|
|
17
|
+
fi
|
|
18
|
+
PB=("$DIR/pocketbase" "--dir" "$DIR/pb_data" "--migrationsDir" "$STARTER/pb/pb_migrations" "--hooksDir" "$STARTER/pb/pb_hooks")
|
|
19
|
+
"${PB[@]}" superuser upsert "$SU_EMAIL" "$SU_PASSWORD" >/dev/null
|
|
20
|
+
# the starter's hooks read AUDITLOG like its Docker entrypoint sets it
|
|
21
|
+
AUDITLOG="${AUDITLOG:-posts,users}" setsid nohup "${PB[@]}" serve --automigrate=0 --http "127.0.0.1:$PORT" > "$DIR/pb.log" 2>&1 < /dev/null &
|
|
22
|
+
echo $! > "$DIR/pb.pid"
|
|
23
|
+
curl --retry 60 --retry-delay 1 --retry-all-errors -s -o /dev/null "http://127.0.0.1:$PORT/api/health"
|
|
24
|
+
TOKEN=$(curl -s -X POST "http://127.0.0.1:$PORT/api/collections/_superusers/auth-with-password" -H "content-type: application/json" -d "{\"identity\":\"$SU_EMAIL\",\"password\":\"$SU_PASSWORD\"}" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
|
|
25
|
+
existing=$(curl -s "http://127.0.0.1:$PORT/api/collections/users/records?filter=email%3D%27$USER_EMAIL%27" -H "authorization: $TOKEN" | sed -n 's/.*"totalItems":\([0-9]*\).*/\1/p')
|
|
26
|
+
if [ "${existing:-0}" = "0" ]; then
|
|
27
|
+
curl -s -o /dev/null -w "user $USER_EMAIL: HTTP %{http_code}\n" -X POST "http://127.0.0.1:$PORT/api/collections/users/records" -H "content-type: application/json" -H "authorization: $TOKEN" -d "{\"email\":\"$USER_EMAIL\",\"password\":\"$USER_PASSWORD\",\"passwordConfirm\":\"$USER_PASSWORD\"}"
|
|
28
|
+
fi
|
|
29
|
+
echo "reference pocketbase $VERSION on http://127.0.0.1:$PORT (pid $(cat "$DIR/pb.pid"), data $DIR/pb_data)"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Runs the unmodified pocketbase-sveltekit-starter frontend (sk) against voidbase instead of PocketBase.
|
|
3
|
+
# scripts/starter.sh start [port] [backend] scripts/starter.sh stop scripts/starter.sh log
|
|
4
|
+
set -u
|
|
5
|
+
cd "$(dirname "$0")/.."
|
|
6
|
+
SK="${STARTER_SK_DIR:-../voidbase-sveltekit-starter/sk}"
|
|
7
|
+
PORT="${2:-5174}"; BACKEND="${3:-http://127.0.0.1:5180}"
|
|
8
|
+
PIDFILE=".void/starter.pid"; LOG=".void/starter.log"
|
|
9
|
+
mkdir -p .void
|
|
10
|
+
running() { [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; }
|
|
11
|
+
case "${1:-status}" in
|
|
12
|
+
start)
|
|
13
|
+
if running; then echo "already running (pid $(cat "$PIDFILE"))"; exit 0; fi
|
|
14
|
+
( cd "$SK" && POCKETBASE_URL="$BACKEND" setsid nohup ./node_modules/.bin/vite dev --port "$PORT" --host 127.0.0.1 --strictPort > "$OLDPWD/$LOG" 2>&1 < /dev/null & echo $! > "$OLDPWD/$PIDFILE" )
|
|
15
|
+
echo "started pid $(cat "$PIDFILE") on http://127.0.0.1:$PORT -> $BACKEND (log: $LOG)"
|
|
16
|
+
curl --retry 60 --retry-delay 1 --retry-all-errors -s -o /dev/null -w "ready: HTTP %{http_code}\n" "http://127.0.0.1:$PORT/" ;;
|
|
17
|
+
stop)
|
|
18
|
+
if running; then pid="$(cat "$PIDFILE")"; kill -TERM -- "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null; for _ in $(seq 1 50); do kill -0 "$pid" 2>/dev/null || break; sleep 0.2; done; kill -0 "$pid" 2>/dev/null && kill -KILL -- "-$pid" 2>/dev/null; echo "stopped"; else echo "not running"; fi
|
|
19
|
+
rm -f "$PIDFILE" ;;
|
|
20
|
+
status) if running; then echo "running (pid $(cat "$PIDFILE"))"; else echo "not running"; fi ;;
|
|
21
|
+
log) tail -n "${2:-40}" "$LOG" ;;
|
|
22
|
+
esac
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Copies a static app build (default: the SvelteKit starter's adapter-static output) into public/ so the same
|
|
2
|
+
// Worker serves the app at / and the PocketBase admin panel at /_/. Everything but public/_ is replaced.
|
|
3
|
+
// bun run app:sync # uses VOIDBASE_APP_DIR or ../pocketbase-sveltekit-starter/sk/build
|
|
4
|
+
import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
|
|
5
|
+
import { resolve } from "node:path";
|
|
6
|
+
|
|
7
|
+
const src = resolve(process.env.VOIDBASE_APP_DIR ?? `${import.meta.dir}/../../pocketbase-sveltekit-starter/sk/build`);
|
|
8
|
+
const destArg = process.argv.indexOf("--dest");
|
|
9
|
+
const dest = resolve(destArg >= 0 ? process.argv[destArg + 1]! : `${import.meta.dir}/../public`);
|
|
10
|
+
if (!existsSync(`${src}/index.html`)) {
|
|
11
|
+
console.error(`app build not found at ${src} (set VOIDBASE_APP_DIR; for the starter run \`bun run build\` in sk/)`);
|
|
12
|
+
process.exit(1);
|
|
13
|
+
}
|
|
14
|
+
mkdirSync(dest, { recursive: true });
|
|
15
|
+
for (const entry of readdirSync(dest)) if (entry !== "_") rmSync(`${dest}/${entry}`, { recursive: true, force: true });
|
|
16
|
+
for (const entry of readdirSync(src)) {
|
|
17
|
+
if (entry === "_") { console.warn("skipping the app's /_ directory: that path belongs to the admin panel"); continue; }
|
|
18
|
+
cpSync(`${src}/${entry}`, `${dest}/${entry}`, { recursive: true });
|
|
19
|
+
}
|
|
20
|
+
console.log(`synced app ${src} -> ${dest} (${statSync(`${dest}/index.html`).size} bytes index.html)`);
|
|
21
|
+
// Cloudflare 404-page handling: deep links get index.html (status 404) from the asset layer, never from the Worker
|
|
22
|
+
if (!existsSync(`${dest}/404.html`)) copyFileSync(`${dest}/index.html`, `${dest}/404.html`);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Copies PocketBase's prebuilt admin panel (ui/dist) into public/_ so the Worker serves it at /_/.
|
|
2
|
+
// The panel is used unmodified; it talks to ../ relative to /_/, i.e. this Worker's /api.
|
|
3
|
+
//
|
|
4
|
+
// Optional branding (VOIDBASE_BRAND_DIR or --brand <dir>): a directory with any of
|
|
5
|
+
// logo.svg, logo_white.svg, favicon.png replace the panel's images
|
|
6
|
+
// brand.json { "title": "...", "docsUrl": "https://..." }
|
|
7
|
+
// title replaces <title> in index.html, docsUrl rewrites the https://pocketbase.io/docs links in the bundles.
|
|
8
|
+
// The panel code itself is untouched; run without a brand dir to get the stock panel back.
|
|
9
|
+
// bun scripts/sync-panel.ts [--brand <dir>] [--dest <dir>]
|
|
10
|
+
// Source: POCKETBASE_UI_DIST, else ../pocketbase/ui/dist, else the pinned release tarball (POCKETBASE_PANEL_VERSION) cached under ~/.cache/voidbase.
|
|
11
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { resolve } from "node:path";
|
|
13
|
+
|
|
14
|
+
const args = Object.fromEntries(process.argv.slice(2).map((a, i, arr) => (a.startsWith("--") ? [a.slice(2), arr[i + 1] ?? "1"] : [])).filter((x) => x.length));
|
|
15
|
+
const PANEL_VERSION = process.env.POCKETBASE_PANEL_VERSION ?? "0.40.2";
|
|
16
|
+
let src = resolve(process.env.POCKETBASE_UI_DIST ?? `${import.meta.dir}/../../pocketbase/ui/dist`);
|
|
17
|
+
const dest = resolve(args.dest ?? `${import.meta.dir}/../public/_`);
|
|
18
|
+
const brandDir = args.brand ?? process.env.VOIDBASE_BRAND_DIR;
|
|
19
|
+
if (!existsSync(`${src}/index.html`)) {
|
|
20
|
+
// no local PocketBase checkout: fetch the committed ui/dist of the pinned release once into a cache
|
|
21
|
+
const cache = resolve(`${process.env.XDG_CACHE_HOME ?? `${process.env.HOME}/.cache`}/voidbase/panel-${PANEL_VERSION}`);
|
|
22
|
+
if (!existsSync(`${cache}/index.html`)) {
|
|
23
|
+
console.log(`downloading PocketBase ${PANEL_VERSION} admin panel (ui/dist) from GitHub`);
|
|
24
|
+
const res = await fetch(`https://codeload.github.com/pocketbase/pocketbase/tar.gz/refs/tags/v${PANEL_VERSION}`);
|
|
25
|
+
if (!res.ok) { console.error(`download failed: HTTP ${res.status} (set POCKETBASE_UI_DIST to a local ui/dist)`); process.exit(1); }
|
|
26
|
+
mkdirSync(cache, { recursive: true });
|
|
27
|
+
const tgz = `${cache}.tgz`; writeFileSync(tgz, new Uint8Array(await res.arrayBuffer()));
|
|
28
|
+
const tar = Bun.spawnSync(["tar", "-xzf", tgz, "-C", cache, "--strip-components=3", `pocketbase-${PANEL_VERSION}/ui/dist`]);
|
|
29
|
+
if (tar.exitCode !== 0) { console.error(new TextDecoder().decode(tar.stderr)); process.exit(1); }
|
|
30
|
+
rmSync(tgz, { force: true });
|
|
31
|
+
}
|
|
32
|
+
src = cache;
|
|
33
|
+
}
|
|
34
|
+
rmSync(dest, { recursive: true, force: true });
|
|
35
|
+
cpSync(src, dest, { recursive: true });
|
|
36
|
+
// PocketBase serves /_/extensions.js (UI extension registry). Without extensions it is an empty module.
|
|
37
|
+
writeFileSync(`${dest}/extensions.js`, "// voidbase: no UI extensions configured\n");
|
|
38
|
+
console.log(`synced panel ${src} -> ${dest} (${statSync(`${dest}/index.html`).size} bytes index.html)`);
|
|
39
|
+
|
|
40
|
+
if (brandDir) {
|
|
41
|
+
const brand = resolve(brandDir);
|
|
42
|
+
const applied: string[] = [];
|
|
43
|
+
for (const img of ["logo.svg", "logo_white.svg", "favicon.png"]) {
|
|
44
|
+
if (existsSync(`${brand}/${img}`)) { cpSync(`${brand}/${img}`, `${dest}/images/${img}`); applied.push(img); }
|
|
45
|
+
}
|
|
46
|
+
const meta = existsSync(`${brand}/brand.json`) ? (JSON.parse(readFileSync(`${brand}/brand.json`, "utf8")) as { title?: string; docsUrl?: string }) : {};
|
|
47
|
+
if (meta.title) {
|
|
48
|
+
const index = `${dest}/index.html`;
|
|
49
|
+
writeFileSync(index, readFileSync(index, "utf8").replace(/<title>[^<]*<\/title>/, `<title>${meta.title.replace(/[<&]/g, "")}</title>`));
|
|
50
|
+
applied.push(`title "${meta.title}"`);
|
|
51
|
+
}
|
|
52
|
+
if (meta.docsUrl) {
|
|
53
|
+
const docsUrl = meta.docsUrl.replace(/\/$/, "");
|
|
54
|
+
let files = 0;
|
|
55
|
+
for (const f of readdirSync(`${dest}/assets`).filter((f) => f.endsWith(".js"))) {
|
|
56
|
+
const p = `${dest}/assets/${f}`; const code = readFileSync(p, "utf8");
|
|
57
|
+
const next = code.replace(/https:\/\/pocketbase\.io\/docs/g, docsUrl);
|
|
58
|
+
if (next !== code) { writeFileSync(p, next); files++; }
|
|
59
|
+
}
|
|
60
|
+
applied.push(`docs links -> ${docsUrl} in ${files} bundles`);
|
|
61
|
+
}
|
|
62
|
+
console.log(`branding from ${brand}: ${applied.length ? applied.join(", ") : "nothing to apply (expected logo.svg, logo_white.svg, favicon.png or brand.json)"}`);
|
|
63
|
+
}
|
|
64
|
+
// Cloudflare 404-page handling: the panel index doubles as its 404 page (PocketBase serves the panel for any /_/ path)
|
|
65
|
+
import { copyFileSync } from "node:fs";
|
|
66
|
+
if (existsSync(`${dest}/index.html`)) copyFileSync(`${dest}/index.html`, `${dest}/404.html`);
|