@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.
Files changed (134) hide show
  1. package/.env.example +9 -0
  2. package/CHANGELOG.md +19 -0
  3. package/COMPAT.md +43 -0
  4. package/LICENSE +21 -0
  5. package/NOTICE +8 -0
  6. package/README.md +124 -0
  7. package/bin/voidbase.ts +158 -0
  8. package/crons/every-minute.ts +13 -0
  9. package/db/migrations/20260905175935_large_swarm.sql +87 -0
  10. package/db/migrations/20260905185720_wild_sunspot.sql +16 -0
  11. package/db/migrations/20260905190723_solid_toro.sql +1 -0
  12. package/db/migrations/20260905213340_remarkable_union_jack.sql +11 -0
  13. package/db/migrations/meta/20260905175935_snapshot.json +599 -0
  14. package/db/migrations/meta/20260905185720_snapshot.json +703 -0
  15. package/db/migrations/meta/20260905190723_snapshot.json +710 -0
  16. package/db/migrations/meta/20260905213340_snapshot.json +781 -0
  17. package/db/migrations/meta/_journal.json +34 -0
  18. package/db/schema.ts +130 -0
  19. package/docs/deploy.md +153 -0
  20. package/docs/differences.md +88 -0
  21. package/docs/hooks.md +84 -0
  22. package/docs/migrating.md +29 -0
  23. package/docs/perf.md +53 -0
  24. package/docs/platform.md +208 -0
  25. package/docs/releasing.md +38 -0
  26. package/env.ts +23 -0
  27. package/hooks-plugin.ts +237 -0
  28. package/package.json +134 -0
  29. package/queues/jobs.ts +13 -0
  30. package/routes/api/[...path].ts +19 -0
  31. package/scripts/bench-realtime.ts +46 -0
  32. package/scripts/bench.ts +39 -0
  33. package/scripts/ci-suites.sh +27 -0
  34. package/scripts/dev.sh +29 -0
  35. package/scripts/export.ts +70 -0
  36. package/scripts/seed-app-user.sh +14 -0
  37. package/scripts/seed-d1.ts +17 -0
  38. package/scripts/seed-reference.sh +29 -0
  39. package/scripts/starter.sh +22 -0
  40. package/scripts/sync-app.ts +22 -0
  41. package/scripts/sync-panel.ts +66 -0
  42. package/src/cloud/rest.ts +297 -0
  43. package/src/node/assets.ts +22 -0
  44. package/src/node/bundle.ts +88 -0
  45. package/src/node/cloud-init.ts +51 -0
  46. package/src/node/d1.ts +44 -0
  47. package/src/node/deploy-cf.ts +179 -0
  48. package/src/node/index.ts +5 -0
  49. package/src/node/panel.ts +21 -0
  50. package/src/node/serve.ts +125 -0
  51. package/src/node/storage.ts +51 -0
  52. package/src/platform/node/env.ts +4 -0
  53. package/src/platform/node/hooks.ts +19 -0
  54. package/src/platform/node/log.ts +7 -0
  55. package/src/platform/node/migrations.ts +5 -0
  56. package/src/platform/node/photon.ts +1 -0
  57. package/src/platform/node/sockets.ts +22 -0
  58. package/src/platform/node/sse.ts +23 -0
  59. package/src/platform/workers/env.ts +3 -0
  60. package/src/platform/workers/hooks.ts +2 -0
  61. package/src/platform/workers/log.ts +1 -0
  62. package/src/platform/workers/migrations.ts +1 -0
  63. package/src/platform/workers/photon.ts +1 -0
  64. package/src/platform/workers/sockets.ts +3 -0
  65. package/src/platform/workers/sse.ts +1 -0
  66. package/src/server/api.ts +27 -0
  67. package/src/server/app.ts +582 -0
  68. package/src/server/auth-extra.ts +113 -0
  69. package/src/server/auth-flows.ts +186 -0
  70. package/src/server/auth-response.ts +111 -0
  71. package/src/server/auth.ts +187 -0
  72. package/src/server/backups.ts +234 -0
  73. package/src/server/batch.ts +123 -0
  74. package/src/server/bootstrap.ts +71 -0
  75. package/src/server/collections/auth-option-shape.json +71 -0
  76. package/src/server/collections/ddl.ts +127 -0
  77. package/src/server/collections/fields.ts +120 -0
  78. package/src/server/collections/model.ts +185 -0
  79. package/src/server/collections/oauth2-providers.json +1 -0
  80. package/src/server/collections/scaffolds.json +210 -0
  81. package/src/server/collections/service.ts +392 -0
  82. package/src/server/collections/system.json +605 -0
  83. package/src/server/collections/system.ts +19 -0
  84. package/src/server/collections/validate.ts +239 -0
  85. package/src/server/crc32.ts +13 -0
  86. package/src/server/crons.ts +100 -0
  87. package/src/server/crypto.ts +26 -0
  88. package/src/server/db.ts +37 -0
  89. package/src/server/errors.ts +53 -0
  90. package/src/server/files-api.ts +52 -0
  91. package/src/server/filter/compile.ts +420 -0
  92. package/src/server/filter/lexer.ts +107 -0
  93. package/src/server/filter/parser.ts +49 -0
  94. package/src/server/hardening.ts +136 -0
  95. package/src/server/hooks/index.ts +147 -0
  96. package/src/server/hooks/migrations.ts +58 -0
  97. package/src/server/hooks/node-async-hooks.d.ts +7 -0
  98. package/src/server/hooks/record.ts +152 -0
  99. package/src/server/hooks/runtime.ts +344 -0
  100. package/src/server/hooks/virtual-migrations.d.ts +4 -0
  101. package/src/server/hooks/virtual.d.ts +7 -0
  102. package/src/server/hub.ts +91 -0
  103. package/src/server/ids.ts +22 -0
  104. package/src/server/jobs.ts +84 -0
  105. package/src/server/jwt.ts +61 -0
  106. package/src/server/logs.ts +144 -0
  107. package/src/server/mail/index.ts +99 -0
  108. package/src/server/mail/message.ts +43 -0
  109. package/src/server/mail/smtp.ts +82 -0
  110. package/src/server/mail/templates.ts +168 -0
  111. package/src/server/oauth2/index.ts +198 -0
  112. package/src/server/oauth2/providers.ts +153 -0
  113. package/src/server/password.ts +17 -0
  114. package/src/server/realtime/hub-client.ts +50 -0
  115. package/src/server/realtime/index.ts +239 -0
  116. package/src/server/records/expand.ts +129 -0
  117. package/src/server/records/files.ts +69 -0
  118. package/src/server/records/json.ts +23 -0
  119. package/src/server/records/picker.ts +80 -0
  120. package/src/server/records/service.ts +598 -0
  121. package/src/server/records/thumbs.ts +148 -0
  122. package/src/server/records/values.ts +295 -0
  123. package/src/server/settings-api.ts +104 -0
  124. package/src/server/settings.ts +215 -0
  125. package/src/server/sql.ts +61 -0
  126. package/src/server/static.ts +17 -0
  127. package/src/server/storage/s3.ts +118 -0
  128. package/src/server/types.ts +25 -0
  129. package/src/server/webauthn.ts +168 -0
  130. package/tsconfig.json +36 -0
  131. package/tsconfig.node.json +27 -0
  132. package/types/pb_data.d.ts +24438 -0
  133. package/vite.config.ts +10 -0
  134. package/void.json +12 -0
@@ -0,0 +1,297 @@
1
+ // Cloudflare REST control plane for voidbase instances, runnable anywhere fetch exists (a Worker, Bun, Node):
2
+ // provision an instance from a prebuilt release (D1 + R2 + queue + Worker script with its assets, cron trigger,
3
+ // Durable Object hub and workers.dev subdomain), destroy one, and list them. `voidbase deploy` (src/node/deploy-cf.ts)
4
+ // is the local counterpart that builds first; this module never builds, it uploads what `voidbase bundle` produced.
5
+ // Every request goes through `CfApi`, so a test can point it at test/cf-mock.ts.
6
+ export const CF_API_BASE = "https://api.cloudflare.com/client/v4";
7
+ // what a control plane must keep (OAuth tokens) goes to rest sealed with VOIDBASE_ENCRYPTION_KEY
8
+ export { sealSecret, openSecret, isSealed } from "../server/crypto";
9
+
10
+ export interface CfErrorItem { code: number; message: string }
11
+ export interface CfResult<T> { success: boolean; errors: CfErrorItem[]; messages: unknown[]; result: T; result_info?: { cursor?: string; is_truncated?: boolean; page?: number; total_pages?: number } }
12
+ export class CfError extends Error {
13
+ constructor(public status: number, public errors: CfErrorItem[], public path: string) { super(`Cloudflare API ${path}: ${status} ${errors.map((e) => `${e.code} ${e.message}`).join("; ") || "unknown error"}`); }
14
+ has(code: number) { return this.errors.some((e) => e.code === code); }
15
+ }
16
+
17
+ export class CfApi {
18
+ constructor(public token: string, base: string = CF_API_BASE) { this.base = base.replace(/\/$/, ""); }
19
+ base: string;
20
+ async raw(method: string, path: string, init: { body?: BodyInit | null; headers?: Record<string, string>; token?: string } = {}): Promise<Response> {
21
+ return fetch(`${this.base}${path}`, { method, body: init.body ?? null, headers: { authorization: `Bearer ${init.token ?? this.token}`, ...(init.headers ?? {}) } });
22
+ }
23
+ async json<T>(method: string, path: string, body?: unknown, tolerate: number[] = []): Promise<CfResult<T>> {
24
+ const res = await this.raw(method, path, { body: body === undefined ? null : JSON.stringify(body), headers: body === undefined ? {} : { "content-type": "application/json" } });
25
+ return this.unwrap<T>(res, path, tolerate);
26
+ }
27
+ async form<T>(method: string, path: string, form: FormData, extra: { token?: string } = {}): Promise<CfResult<T>> {
28
+ const res = await this.raw(method, path, { body: form, token: extra.token });
29
+ return this.unwrap<T>(res, path, []);
30
+ }
31
+ private async unwrap<T>(res: Response, path: string, tolerate: number[]): Promise<CfResult<T>> {
32
+ const text = await res.text();
33
+ let parsed: CfResult<T> | null = null;
34
+ try { parsed = JSON.parse(text) as CfResult<T>; } catch { /* not JSON */ }
35
+ if (!parsed) { if (res.ok) return { success: true, errors: [], messages: [], result: undefined as T }; throw new CfError(res.status, [{ code: 0, message: text.slice(0, 300) }], path); }
36
+ if (parsed.success === false && !(parsed.errors ?? []).some((e) => tolerate.includes(e.code))) throw new CfError(res.status, parsed.errors ?? [], path);
37
+ return parsed;
38
+ }
39
+ }
40
+
41
+ // ---- accounts and identity --------------------------------------------------------------------------------
42
+ export interface CfAccount { id: string; name: string }
43
+ export interface CfUser { id: string; email: string; first_name?: string | null; last_name?: string | null; username?: string | null }
44
+ export async function listAccounts(cf: CfApi): Promise<CfAccount[]> {
45
+ const out: CfAccount[] = [];
46
+ for (let page = 1; page < 20; page++) {
47
+ const r = await cf.json<CfAccount[]>("GET", `/accounts?page=${page}&per_page=50`);
48
+ out.push(...(r.result ?? []).map((a) => ({ id: a.id, name: a.name })));
49
+ if (!r.result_info || !r.result_info.total_pages || page >= r.result_info.total_pages) break;
50
+ }
51
+ return out;
52
+ }
53
+ export const currentUser = async (cf: CfApi): Promise<CfUser> => (await cf.json<CfUser>("GET", "/user")).result;
54
+ export async function resolveAccount(cf: CfApi, wanted?: string): Promise<CfAccount> {
55
+ let accounts: CfAccount[];
56
+ try { accounts = await listAccounts(cf); } catch (e) { throw new Error(`cannot list accounts with this token (${e instanceof Error ? e.message : e})`); }
57
+ if (!accounts.length) throw new Error("the token reaches no account");
58
+ if (wanted) { const a = accounts.find((x) => x.id === wanted || x.name === wanted); if (!a) throw new Error(`account ${wanted} is not reachable with this token (${accounts.map((x) => `${x.name} ${x.id}`).join(", ")})`); return a; }
59
+ if (accounts.length > 1) throw new Error(`the token reaches ${accounts.length} accounts, pick one: ${accounts.map((x) => `${x.name} (${x.id})`).join(", ")}`);
60
+ return accounts[0]!;
61
+ }
62
+
63
+ // ---- resources: one D1, one R2 bucket, one queue per instance, all named from the worker name -----------------
64
+ export const instanceResources = (name: string) => ({ db: `${name}-db`, bucket: `${name}-storage`, queue: `${name}-jobs`, dataset: `${name.replace(/-/g, "_")}_requests` });
65
+ export async function ensureD1(cf: CfApi, account: string, name: string): Promise<{ uuid: string; created: boolean }> {
66
+ const list = await cf.json<{ uuid: string; name: string }[]>("GET", `/accounts/${account}/d1/database?name=${encodeURIComponent(name)}&per_page=100`);
67
+ const hit = (list.result ?? []).find((d) => d.name === name);
68
+ if (hit) return { uuid: hit.uuid, created: false };
69
+ try { const made = await cf.json<{ uuid: string }>("POST", `/accounts/${account}/d1/database`, { name }); return { uuid: made.result.uuid, created: true }; }
70
+ catch (e) { if (e instanceof CfError && e.has(7406)) throw new Error(`creating the D1 database ${name}: the account is at its D1 database limit (${e.errors.map((x) => x.message).join("; ")}). Delete an unused database or upgrade the Workers plan; nothing was created.`); throw e; }
71
+ }
72
+ export async function findD1(cf: CfApi, account: string, name: string): Promise<{ uuid: string } | null> {
73
+ const list = await cf.json<{ uuid: string; name: string }[]>("GET", `/accounts/${account}/d1/database?name=${encodeURIComponent(name)}&per_page=100`);
74
+ const hit = (list.result ?? []).find((d) => d.name === name); return hit ? { uuid: hit.uuid } : null;
75
+ }
76
+ export async function ensureR2(cf: CfApi, account: string, name: string): Promise<{ created: boolean }> {
77
+ const head = await cf.raw("GET", `/accounts/${account}/r2/buckets/${encodeURIComponent(name)}`); await head.text();
78
+ if (head.ok) return { created: false };
79
+ await cf.json("POST", `/accounts/${account}/r2/buckets`, { name }, [10004]); return { created: true };
80
+ }
81
+ export async function findQueue(cf: CfApi, account: string, name: string): Promise<{ id: string } | null> {
82
+ const list = await cf.json<{ queue_id: string; queue_name: string }[]>("GET", `/accounts/${account}/queues?per_page=100`);
83
+ const hit = (list.result ?? []).find((q) => q.queue_name === name); return hit ? { id: hit.queue_id } : null;
84
+ }
85
+ export async function ensureQueue(cf: CfApi, account: string, name: string): Promise<{ id: string | null; created: boolean; reason?: string }> {
86
+ try {
87
+ const hit = await findQueue(cf, account, name); if (hit) return { id: hit.id, created: false };
88
+ const made = await cf.json<{ queue_id: string }>("POST", `/accounts/${account}/queues`, { queue_name: name });
89
+ return { id: made.result.queue_id, created: true };
90
+ } catch (e) { return { id: null, created: false, reason: e instanceof Error ? e.message : String(e) }; }
91
+ }
92
+ export async function workersSubdomain(cf: CfApi, account: string): Promise<string | null> {
93
+ try { const r = await cf.json<{ subdomain?: string }>("GET", `/accounts/${account}/workers/subdomain`); return r.result?.subdomain ?? null; } catch { return null; }
94
+ }
95
+ // Cloudflare shares rate-limit counters between bindings with the same namespace id, even across Workers: derive it per instance
96
+ export function rateLimitNamespace(name: string): string { let h = 2166136261; for (const ch of name) { h ^= ch.charCodeAt(0); h = Math.imul(h, 16777619) >>> 0; } return String(1000 + (h % 900000)); }
97
+
98
+ // ---- releases: what `voidbase bundle` produces -------------------------------------------------------------
99
+ export interface ReleaseModule { path: string; type: "esm" | "wasm" | "text" | "data"; size: number }
100
+ export interface ReleaseAsset { path: string; size: number; hash: string; contentType: string }
101
+ export interface ReleaseManifest {
102
+ version: string; voidbase: string; builtAt: string;
103
+ compatibilityDate: string; compatibilityFlags: string[]; mainModule: string;
104
+ modules: ReleaseModule[]; assets: ReleaseAsset[]; migrations: { name: string; size: number }[];
105
+ crons: string[]; durableObjects: { binding: string; className: string; tag: string }[];
106
+ queueBinding: string | null; assetsConfig: Record<string, unknown>;
107
+ }
108
+ /** bytes of one release file: "worker/<module path>", "assets/<asset path>", "migrations/<file>" */
109
+ export interface ReleaseSource { manifest: ReleaseManifest; read(path: string): Promise<Uint8Array> }
110
+
111
+ export function toBase64(bytes: Uint8Array): string { let s = ""; for (let i = 0; i < bytes.length; i += 0x8000) s += String.fromCharCode(...bytes.subarray(i, i + 0x8000)); return btoa(s); }
112
+ export async function assetHash(bytes: Uint8Array): Promise<string> { const d = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes as BufferSource)); return [...d].map((b) => b.toString(16).padStart(2, "0")).join("").slice(0, 32); }
113
+ export function contentTypeFor(path: string): string {
114
+ const ext = path.slice(path.lastIndexOf(".") + 1).toLowerCase();
115
+ return ({ html: "text/html; charset=utf-8", js: "text/javascript; charset=utf-8", mjs: "text/javascript; charset=utf-8", css: "text/css; charset=utf-8", json: "application/json", svg: "image/svg+xml", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp", ico: "image/x-icon", woff: "font/woff", woff2: "font/woff2", ttf: "font/ttf", txt: "text/plain; charset=utf-8", map: "application/json", wasm: "application/wasm", xml: "application/xml", webmanifest: "application/manifest+json" } as Record<string, string>)[ext] ?? "application/octet-stream";
116
+ }
117
+ const moduleMime = (t: ReleaseModule["type"]) => ({ esm: "application/javascript+module", wasm: "application/wasm", text: "text/plain", data: "application/octet-stream" })[t];
118
+
119
+ // ---- provisioning ----------------------------------------------------------------------------------------
120
+ export interface ProvisionOptions {
121
+ account: string; name: string; release: ReleaseSource;
122
+ superuser: { email: string; password: string };
123
+ /** plain-text worker vars and secrets (VOIDBASE_* knobs, the hooks' AUDITLOG, ...) */
124
+ vars?: Record<string, string>; secrets?: Record<string, string>;
125
+ queue?: boolean; hub?: boolean; cron?: boolean; rateLimit?: { limit: number; period: 10 | 60 } | null; smartPlacement?: boolean;
126
+ /** send the Durable Object migrations: true on the first upload, false when the deployed script already has the tag */
127
+ applyDoMigrations?: boolean;
128
+ tags?: string[]; log?: (line: string) => void;
129
+ }
130
+ export interface ProvisionResult { name: string; account: string; url: string | null; d1: { uuid: string; created: boolean }; queue: { id: string; created: boolean } | null; bucket: { created: boolean }; release: string; assets: number; modules: number; migrationsApplied: string[] }
131
+
132
+ export async function provisionInstance(cf: CfApi, o: ProvisionOptions): Promise<ProvisionResult> {
133
+ const log = o.log ?? (() => undefined); const m = o.release.manifest; const res = instanceResources(o.name);
134
+ if (!/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/.test(o.name)) throw new Error(`invalid worker name "${o.name}": lowercase letters, digits and dashes, 1-63 chars`);
135
+ const d1 = await ensureD1(cf, o.account, res.db); log(`D1 ${res.db} ${d1.created ? "created" : "exists"} (${d1.uuid})`);
136
+ const bucket = await ensureR2(cf, o.account, res.bucket); log(`R2 ${res.bucket} ${bucket.created ? "created" : "exists"}`);
137
+ let queue: ProvisionResult["queue"] = null;
138
+ if (o.queue !== false && m.queueBinding) {
139
+ const q = await ensureQueue(cf, o.account, res.queue);
140
+ if (q.id) { queue = { id: q.id, created: q.created }; log(`Queue ${res.queue} ${q.created ? "created" : "exists"}`); }
141
+ else log(`Queue ${res.queue} not created (${q.reason}); mail and backups run inline`);
142
+ }
143
+ const migrationsApplied = await applyD1Migrations(cf, o.account, d1.uuid, o.release, log);
144
+
145
+ // static assets (the admin panel and the 404 shells): manifest, then the buckets the API asks for, then the completion token
146
+ const manifest: Record<string, { hash: string; size: number }> = {};
147
+ for (const a of m.assets) manifest[`/${a.path}`] = { hash: a.hash, size: a.size };
148
+ let assetsJwt: string | null = null;
149
+ if (m.assets.length) {
150
+ const session = await cf.json<{ jwt: string; buckets: string[][] }>("POST", `/accounts/${o.account}/workers/scripts/${o.name}/assets-upload-session`, { manifest });
151
+ assetsJwt = session.result.jwt;
152
+ const byHash = new Map(m.assets.map((a) => [a.hash, a] as const));
153
+ let uploaded = 0;
154
+ for (const bucketHashes of session.result.buckets ?? []) {
155
+ const form = new FormData();
156
+ for (const h of bucketHashes) { const a = byHash.get(h); if (!a) throw new Error(`asset upload: unknown hash ${h}`); const bytes = await o.release.read(`assets/${a.path}`); form.append(h, new Blob([toBase64(bytes)], { type: a.contentType }), h); uploaded++; }
157
+ const r = await cf.form<{ jwt?: string }>("POST", `/accounts/${o.account}/workers/assets/upload?base64=true`, form, { token: session.result.jwt });
158
+ if (r.result?.jwt) assetsJwt = r.result.jwt;
159
+ }
160
+ log(`assets: ${m.assets.length} files, ${uploaded} uploaded`);
161
+ }
162
+
163
+ // the Worker itself: metadata part + every module of the release
164
+ const bindings: Record<string, unknown>[] = [
165
+ { type: "d1", name: "DB", id: d1.uuid },
166
+ { type: "r2_bucket", name: "STORAGE", bucket_name: res.bucket },
167
+ { type: "assets", name: "ASSETS" },
168
+ ];
169
+ if (queue && m.queueBinding) bindings.push({ type: "queue", name: m.queueBinding, queue_name: res.queue });
170
+ if (o.hub !== false) for (const d of m.durableObjects) bindings.push({ type: "durable_object_namespace", name: d.binding, class_name: d.className });
171
+ if (o.rateLimit) bindings.push({ type: "ratelimit", name: "RATE_LIMITER", namespace_id: rateLimitNamespace(o.name), simple: { limit: o.rateLimit.limit, period: o.rateLimit.period } });
172
+ for (const [k, v] of Object.entries(o.vars ?? {})) bindings.push({ type: "plain_text", name: k, text: v });
173
+ const secrets = { VOIDBASE_SUPERUSER_EMAIL: o.superuser.email, VOIDBASE_SUPERUSER_PASSWORD: o.superuser.password, ...(o.secrets ?? {}) };
174
+ for (const [k, v] of Object.entries(secrets)) bindings.push({ type: "secret_text", name: k, text: v });
175
+ const metadata: Record<string, unknown> = {
176
+ main_module: m.mainModule, compatibility_date: m.compatibilityDate, compatibility_flags: m.compatibilityFlags, bindings,
177
+ ...(o.smartPlacement === false ? {} : { placement: { mode: "smart" } }),
178
+ ...(assetsJwt ? { assets: { jwt: assetsJwt, config: m.assetsConfig } } : {}),
179
+ ...(o.hub !== false && o.applyDoMigrations !== false && m.durableObjects.length ? { migrations: m.durableObjects.map((d) => ({ tag: d.tag, new_sqlite_classes: [d.className] })) } : {}),
180
+ tags: ["voidbase", `voidbase-release:${m.version}`, ...(o.tags ?? [])],
181
+ };
182
+ const form = new FormData();
183
+ form.append("metadata", new Blob([JSON.stringify(metadata)], { type: "application/json" }), "metadata.json");
184
+ for (const mod of m.modules) form.append(mod.path, new Blob([await o.release.read(`worker/${mod.path}`) as BlobPart], { type: moduleMime(mod.type) }), mod.path);
185
+ await cf.form("PUT", `/accounts/${o.account}/workers/scripts/${o.name}`, form);
186
+ log(`worker ${o.name} uploaded (${m.modules.length} modules, release ${m.version})`);
187
+
188
+ if (queue) {
189
+ const consumers = await cf.json<{ script?: string; script_name?: string }[]>("GET", `/accounts/${o.account}/queues/${queue.id}/consumers`);
190
+ if (!(consumers.result ?? []).some((c) => (c.script ?? c.script_name) === o.name)) await cf.json("POST", `/accounts/${o.account}/queues/${queue.id}/consumers`, { type: "worker", script_name: o.name, settings: { batch_size: 10, max_retries: 5, max_wait_time_ms: 1000, retry_delay: 30 } });
191
+ }
192
+ if (m.crons.length && o.cron !== false) {
193
+ // Workers Free allows 5 cron triggers per account (code 10072): the instance still works, maintenance runs lazily in requests
194
+ try { await cf.json("PUT", `/accounts/${o.account}/workers/scripts/${o.name}/schedules`, m.crons.map((cron) => ({ cron }))); }
195
+ catch (e) { if (e instanceof CfError && e.has(10072)) log(`cron trigger skipped: ${e.errors[0]?.message ?? "account cron limit"}`); else throw e; }
196
+ }
197
+ await cf.json("POST", `/accounts/${o.account}/workers/scripts/${o.name}/subdomain`, { enabled: true, previews_enabled: false });
198
+ const sub = await workersSubdomain(cf, o.account);
199
+ const url = sub ? `https://${o.name}.${sub}.workers.dev` : null;
200
+ log(`live: ${url ?? "(workers.dev subdomain not enabled on the account)"}`);
201
+ return { name: o.name, account: o.account, url, d1, queue, bucket, release: m.version, assets: m.assets.length, modules: m.modules.length, migrationsApplied };
202
+ }
203
+
204
+ // D1 migrations through the REST /query endpoint, tracked in wrangler's d1_migrations table so a later local
205
+ // `voidbase deploy` (wrangler d1 migrations apply) sees them as applied
206
+ export async function applyD1Migrations(cf: CfApi, account: string, uuid: string, release: ReleaseSource, log: (l: string) => void = () => undefined): Promise<string[]> {
207
+ const q = (sql: string, params: unknown[] = []) => cf.json<{ results: Record<string, unknown>[] }[]>("POST", `/accounts/${account}/d1/database/${uuid}/query`, { sql, params });
208
+ await q("CREATE TABLE IF NOT EXISTS d1_migrations(id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT UNIQUE, applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)");
209
+ const done = new Set(((await q("SELECT name FROM d1_migrations")).result?.[0]?.results ?? []).map((r) => String(r.name)));
210
+ const applied: string[] = [];
211
+ for (const mig of [...release.manifest.migrations].sort((a, b) => a.name.localeCompare(b.name))) {
212
+ if (done.has(mig.name)) continue;
213
+ const sql = new TextDecoder().decode(await release.read(`migrations/${mig.name}`));
214
+ for (const stmt of sql.split(/-->\s*statement-breakpoint/).map((s) => s.trim()).filter(Boolean)) await q(stmt);
215
+ await q("INSERT INTO d1_migrations (name) VALUES (?)", [mig.name]);
216
+ applied.push(mig.name);
217
+ }
218
+ log(`D1 migrations: ${applied.length} applied, ${done.size} already there`);
219
+ return applied;
220
+ }
221
+
222
+ // ---- custom domains (Workers Custom Domains: Cloudflare adds the DNS record and certificate; Workers Scripts Write suffices) ----
223
+ export interface CustomDomain { id: string; hostname: string; service: string; zone_id: string; zone_name?: string; environment?: string }
224
+ /** the zone of a hostname on the account: api.example.com -> example.com (walks the labels up) */
225
+ export async function findZone(cf: CfApi, hostname: string, account?: string): Promise<{ id: string; name: string } | null> {
226
+ const labels = hostname.toLowerCase().split(".").filter(Boolean);
227
+ for (let i = 0; i < labels.length - 1; i++) {
228
+ const name = labels.slice(i).join(".");
229
+ const r = await cf.json<{ id: string; name: string }[]>("GET", `/zones?name=${encodeURIComponent(name)}${account ? `&account.id=${account}` : ""}`);
230
+ const z = (r.result ?? []).find((x) => x.name === name);
231
+ if (z) return { id: z.id, name: z.name };
232
+ }
233
+ return null;
234
+ }
235
+ export async function listCustomDomains(cf: CfApi, account: string, filter: { service?: string; hostname?: string } = {}): Promise<CustomDomain[]> {
236
+ const q = new URLSearchParams(); if (filter.service) q.set("service", filter.service); if (filter.hostname) q.set("hostname", filter.hostname);
237
+ const r = await cf.json<CustomDomain[]>("GET", `/accounts/${account}/workers/domains${q.size ? `?${q}` : ""}`);
238
+ return (r.result ?? []).filter((d) => (!filter.service || d.service === filter.service) && (!filter.hostname || d.hostname === filter.hostname));
239
+ }
240
+ export async function attachCustomDomain(cf: CfApi, account: string, o: { hostname: string; service: string; environment?: string; zoneId?: string }): Promise<CustomDomain & { created: boolean }> {
241
+ const hostname = o.hostname.toLowerCase();
242
+ const hit = (await listCustomDomains(cf, account, { hostname })).find((d) => d.service === o.service);
243
+ if (hit) return { ...hit, created: false };
244
+ const zone = o.zoneId ? { id: o.zoneId } : await findZone(cf, hostname, account);
245
+ if (!zone) throw new Error(`no zone on account ${account} covers ${hostname}: add the domain to Cloudflare first`);
246
+ const r = await cf.json<CustomDomain>("PUT", `/accounts/${account}/workers/domains`, { hostname, service: o.service, environment: o.environment ?? "production", zone_id: zone.id });
247
+ return { ...r.result, created: true };
248
+ }
249
+ export async function detachCustomDomains(cf: CfApi, account: string, service: string): Promise<string[]> {
250
+ const gone: string[] = [];
251
+ for (const d of await listCustomDomains(cf, account, { service })) { await cf.json("DELETE", `/accounts/${account}/workers/domains/${d.id}`); gone.push(d.hostname); }
252
+ return gone;
253
+ }
254
+
255
+ // ---- teardown ---------------------------------------------------------------------------------------------
256
+ export interface DestroyResult { name: string; deleted: string[]; skipped: string[]; errors: string[] }
257
+ export async function destroyInstance(cf: CfApi, o: { account: string; name: string; log?: (l: string) => void }): Promise<DestroyResult> {
258
+ const log = o.log ?? (() => undefined); const res = instanceResources(o.name); const out: DestroyResult = { name: o.name, deleted: [], skipped: [], errors: [] };
259
+ const attempt = async (label: string, fn: () => Promise<boolean>) => { try { (await fn()) ? out.deleted.push(label) : out.skipped.push(label); log(`${label}: ${out.deleted.includes(label) ? "deleted" : "not found"}`); } catch (e) { out.errors.push(`${label}: ${e instanceof Error ? e.message : e}`); log(`${label}: ${e instanceof Error ? e.message : e}`); } };
260
+ await attempt(`custom domains of ${o.name}`, async () => (await detachCustomDomains(cf, o.account, o.name)).length > 0);
261
+ // the script first so nothing keeps serving with bindings that are about to vanish
262
+ await attempt(`worker ${o.name}`, async () => { const r = await cf.raw("DELETE", `/accounts/${o.account}/workers/scripts/${o.name}?force=true`); await r.text(); if (r.status === 404) return false; if (!r.ok) throw new Error(`HTTP ${r.status}`); return true; });
263
+ await attempt(`queue ${res.queue}`, async () => { const q = await findQueue(cf, o.account, res.queue); if (!q) return false; await cf.json("DELETE", `/accounts/${o.account}/queues/${q.id}`); return true; });
264
+ await attempt(`D1 ${res.db}`, async () => { const d = await findD1(cf, o.account, res.db); if (!d) return false; await cf.json("DELETE", `/accounts/${o.account}/d1/database/${d.uuid}`); return true; });
265
+ await attempt(`R2 ${res.bucket}`, async () => {
266
+ const head = await cf.raw("GET", `/accounts/${o.account}/r2/buckets/${encodeURIComponent(res.bucket)}`); await head.text(); if (head.status === 404) return false;
267
+ for (let cursor: string | undefined; ;) { // a bucket must be empty before it can go
268
+ const page = await cf.json<{ key: string }[]>("GET", `/accounts/${o.account}/r2/buckets/${encodeURIComponent(res.bucket)}/objects?per_page=1000${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`);
269
+ for (const obj of page.result ?? []) { const r = await cf.raw("DELETE", `/accounts/${o.account}/r2/buckets/${encodeURIComponent(res.bucket)}/objects/${encodeURIComponent(obj.key)}`); await r.text(); }
270
+ if (!page.result_info?.is_truncated || !page.result_info.cursor) break; cursor = page.result_info.cursor;
271
+ }
272
+ await cf.json("DELETE", `/accounts/${o.account}/r2/buckets/${encodeURIComponent(res.bucket)}`); return true;
273
+ });
274
+ return out;
275
+ }
276
+
277
+ // ---- inspection --------------------------------------------------------------------------------------------
278
+ export interface WorkerInfo { name: string; tags: string[]; created_on?: string; modified_on?: string; release: string | null }
279
+ export async function listVoidbaseWorkers(cf: CfApi, account: string): Promise<WorkerInfo[]> {
280
+ const r = await cf.json<{ id: string; tags?: string[]; created_on?: string; modified_on?: string }[]>("GET", `/accounts/${account}/workers/scripts`);
281
+ return (r.result ?? []).filter((s) => (s.tags ?? []).includes("voidbase")).map((s) => ({ name: s.id, tags: s.tags ?? [], created_on: s.created_on, modified_on: s.modified_on, release: (s.tags ?? []).find((t) => t.startsWith("voidbase-release:"))?.slice("voidbase-release:".length) ?? null }));
282
+ }
283
+ export async function workerExists(cf: CfApi, account: string, name: string): Promise<boolean> {
284
+ const r = await cf.raw("GET", `/accounts/${account}/workers/scripts/${name}/settings`); await r.text();
285
+ if (r.status === 404) return false; if (!r.ok) throw new Error(`Cloudflare API workers/scripts/${name}/settings: HTTP ${r.status}`); return true;
286
+ }
287
+
288
+ // ---- OAuth token refresh (Cloudflare OAuth clients get refresh tokens with the offline_access scope) -----------
289
+ export interface OAuthTokens { access_token: string; refresh_token?: string; expires_in?: number; token_type?: string; scope?: string }
290
+ export async function refreshOAuthToken(o: { tokenURL: string; clientId: string; clientSecret?: string; refreshToken: string }): Promise<OAuthTokens> {
291
+ const body = new URLSearchParams({ grant_type: "refresh_token", refresh_token: o.refreshToken, client_id: o.clientId });
292
+ if (o.clientSecret) body.set("client_secret", o.clientSecret);
293
+ const res = await fetch(o.tokenURL, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" }, body });
294
+ const text = await res.text();
295
+ if (!res.ok) throw new Error(`token refresh failed (${res.status}): ${text.slice(0, 200)}`);
296
+ return JSON.parse(text) as OAuthTokens;
297
+ }
@@ -0,0 +1,22 @@
1
+ // The ASSETS fetcher for `voidbase serve`: the admin panel under /_/ and the public directory for everything else.
2
+ import { existsSync, statSync } from "node:fs";
3
+ import { join, normalize } from "node:path";
4
+ export function assetsFetcher(opts: { panelDir: string; publicDir?: string }) {
5
+ const file = (root: string, rel: string): string | null => {
6
+ const p = normalize(join(root, rel));
7
+ if (!p.startsWith(normalize(root))) return null;
8
+ if (existsSync(p) && statSync(p).isFile()) return p;
9
+ const index = join(p, "index.html");
10
+ return existsSync(index) ? index : null;
11
+ };
12
+ return {
13
+ async fetch(req: Request): Promise<Response> {
14
+ const url = new URL(req.url); const path = decodeURIComponent(url.pathname);
15
+ const target = path === "/_" || path.startsWith("/_/") ? file(opts.panelDir, path.slice(2) || "/") : opts.publicDir ? file(opts.publicDir, path) : null;
16
+ if (!target) return new Response("not found", { status: 404 });
17
+ const f = Bun.file(target);
18
+ const headers = { "content-type": f.type || "application/octet-stream", "content-length": String(f.size) };
19
+ return new Response(req.method === "HEAD" ? null : f, { status: 200, headers });
20
+ },
21
+ };
22
+ }
@@ -0,0 +1,88 @@
1
+ // `voidbase bundle`: build the generic voidbase Worker once (no project hooks, the stock admin panel) and lay it out
2
+ // as a release directory that `voidbase/cloud` can upload over the REST API from anywhere, including from inside
3
+ // another voidbase instance (the site's control plane creates instances from the release stored in its own R2).
4
+ // <out>/manifest.json ReleaseManifest (compat date/flags, modules, assets with hashes, migrations, crons, hub)
5
+ // <out>/worker/<module> dist/ssr/* of the Void build (index.js + chunks + wasm)
6
+ // <out>/assets/<path> dist/client/* (panel under _/, 404 shells)
7
+ // <out>/migrations/<file>.sql db/migrations (Drizzle SQL with statement-breakpoint markers)
8
+ import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
9
+ import { join, relative, resolve } from "node:path";
10
+ import { writeCloudProject } from "./cloud-init";
11
+ import { assetHash, contentTypeFor, type ReleaseManifest, type ReleaseSource } from "../cloud/rest";
12
+
13
+ const PKG = resolve(import.meta.dir, "../..");
14
+ export interface BundleOptions { out?: string; version?: string; hub?: boolean; queue?: boolean; log?: (line: string) => void; keepProject?: boolean }
15
+
16
+ const walk = (dir: string, base = dir): string[] => readdirSync(dir).flatMap((n) => { const f = join(dir, n); return statSync(f).isDirectory() ? walk(f, base) : [relative(base, f).replace(/\\/g, "/")]; });
17
+ const sh = async (cmd: string[], cwd: string) => { const p = Bun.spawn(cmd, { cwd, stdout: "pipe", stderr: "pipe", env: process.env }); const [out, errText, code] = await Promise.all([new Response(p.stdout).text(), new Response(p.stderr).text(), p.exited]); if (code !== 0) throw new Error(`${cmd.join(" ")} exited with ${code}\n${out.slice(-2000)}\n${errText.slice(-2000)}`); return out; };
18
+
19
+ export async function buildRelease(o: BundleOptions = {}): Promise<{ dir: string; manifest: ReleaseManifest }> {
20
+ const log = o.log ?? ((l: string) => console.log(l));
21
+ const pkg = JSON.parse(readFileSync(`${PKG}/package.json`, "utf8")) as { version: string };
22
+ const stamp = new Date().toISOString().replace(/[-:]/g, "").replace("T", ".").slice(0, 13); // 20260906.1102
23
+ const version = (o.version || `${pkg.version}-${stamp}`).replace(/[^A-Za-z0-9._-]/g, "-");
24
+ const src = resolve(PKG, ".cloud/_release-src"); mkdirSync(`${src}/pb_hooks`, { recursive: true }); mkdirSync(`${src}/pb_migrations`, { recursive: true });
25
+ const cloud = resolve(PKG, ".cloud/_release"); rmSync(cloud, { recursive: true, force: true });
26
+ const hub = o.hub !== false; const queue = o.queue !== false;
27
+ writeCloudProject(cloud, "internal", { hooksDir: `${src}/pb_hooks`, migrationsDir: `${src}/pb_migrations`, queue: queue ? "jobs" : false, hub });
28
+ // placeholder ids: the real bindings are set at upload time from the manifest
29
+ writeFileSync(`${cloud}/wrangler.jsonc`, JSON.stringify({
30
+ name: "voidbase", placement: { mode: "smart" },
31
+ d1_databases: [{ binding: "DB", database_name: "voidbase-db", database_id: "00000000-0000-0000-0000-000000000000", migrations_dir: "./db/migrations" }],
32
+ r2_buckets: [{ binding: "STORAGE", bucket_name: "voidbase-storage" }],
33
+ ...(hub ? { durable_objects: { bindings: [{ name: "HUB", class_name: "VoidbaseHub" }] }, migrations: [{ tag: "voidbase-hub-v1", new_sqlite_classes: ["VoidbaseHub"] }] } : {}),
34
+ }, null, 2));
35
+ writeFileSync(`${cloud}/.env`, "");
36
+ mkdirSync(`${cloud}/public`, { recursive: true });
37
+ log(`building release ${version} in ${cloud}`);
38
+ await sh(["bun", resolve(PKG, "scripts/sync-panel.ts"), "--dest", `${cloud}/public/_`], cloud);
39
+ await sh(["bun", resolve(PKG, "node_modules/.bin/vp"), "build"], cloud);
40
+ const W = JSON.parse(readFileSync(`${cloud}/dist/ssr/wrangler.json`, "utf8")) as Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
41
+ const ssr = `${cloud}/dist/ssr`, client = `${cloud}/dist/client`;
42
+ const modules = walk(ssr).filter((f) => f !== "wrangler.json" && !f.startsWith(".vite/")).map((path) => ({ path, type: /\.(m?js)$/.test(path) ? "esm" as const : path.endsWith(".wasm") ? "wasm" as const : "data" as const, size: statSync(join(ssr, path)).size }));
43
+ const ignore = new Set([".assetsignore", "wrangler.json", ".dev.vars", ...(existsSync(`${client}/.assetsignore`) ? readFileSync(`${client}/.assetsignore`, "utf8").split(/\r?\n/).map((l) => l.trim()).filter(Boolean) : [])]);
44
+ const assets: ReleaseManifest["assets"] = [];
45
+ for (const path of walk(client)) { if (ignore.has(path) || ignore.has(path.split("/")[0]!)) continue; const bytes = new Uint8Array(readFileSync(join(client, path))); assets.push({ path, size: bytes.length, hash: await assetHash(bytes), contentType: contentTypeFor(path) }); }
46
+ const migrations = readdirSync(`${PKG}/db/migrations`).filter((f) => f.endsWith(".sql")).sort().map((name) => ({ name, size: statSync(`${PKG}/db/migrations/${name}`).size }));
47
+ const doBindings = ((W.durable_objects?.bindings ?? []) as { name: string; class_name: string }[]).map((b) => ({ binding: b.name, className: b.class_name, tag: String(((W.migrations ?? []) as { tag: string; new_sqlite_classes?: string[]; new_classes?: string[] }[]).find((m) => (m.new_sqlite_classes ?? m.new_classes ?? []).includes(b.class_name))?.tag ?? "voidbase-hub-v1") }));
48
+ const manifest: ReleaseManifest = {
49
+ version, voidbase: pkg.version, builtAt: new Date().toISOString(),
50
+ compatibilityDate: String(W.compatibility_date), compatibilityFlags: (W.compatibility_flags ?? []) as string[], mainModule: String(W.main ?? "index.js"),
51
+ modules, assets, migrations, crons: ((W.triggers?.crons ?? []) as string[]), durableObjects: hub ? doBindings : [],
52
+ queueBinding: queue ? String(W.queues?.producers?.[0]?.binding ?? "QUEUE_JOBS") : null,
53
+ assetsConfig: { ...(W.assets?.html_handling ? { html_handling: W.assets.html_handling } : {}), ...(W.assets?.not_found_handling ? { not_found_handling: W.assets.not_found_handling } : {}), ...(W.assets?.run_worker_first ? { run_worker_first: W.assets.run_worker_first } : {}) },
54
+ };
55
+ const dir = resolve(o.out ?? resolve(PKG, ".cloud/releases", version)); rmSync(dir, { recursive: true, force: true }); mkdirSync(dir, { recursive: true });
56
+ for (const m of modules) { mkdirSync(resolve(dir, "worker", m.path, ".."), { recursive: true }); cpSync(join(ssr, m.path), resolve(dir, "worker", m.path)); }
57
+ for (const a of assets) { mkdirSync(resolve(dir, "assets", a.path, ".."), { recursive: true }); cpSync(join(client, a.path), resolve(dir, "assets", a.path)); }
58
+ mkdirSync(resolve(dir, "migrations"), { recursive: true });
59
+ for (const m of migrations) cpSync(`${PKG}/db/migrations/${m.name}`, resolve(dir, "migrations", m.name));
60
+ writeFileSync(resolve(dir, "manifest.json"), JSON.stringify(manifest, null, 2) + "\n");
61
+ const total = modules.reduce((n, m) => n + m.size, 0) + assets.reduce((n, a) => n + a.size, 0);
62
+ log(`release ${version}: ${modules.length} modules, ${assets.length} assets, ${migrations.length} migrations, ${(total / 1024 / 1024).toFixed(1)} MB -> ${dir}`);
63
+ if (!o.keepProject) rmSync(cloud, { recursive: true, force: true });
64
+ return { dir, manifest };
65
+ }
66
+
67
+ /** a release directory as a ReleaseSource (Bun/Node side) */
68
+ export function releaseFromDir(dir: string): ReleaseSource {
69
+ const manifest = JSON.parse(readFileSync(resolve(dir, "manifest.json"), "utf8")) as ReleaseManifest;
70
+ return { manifest, read: async (path) => new Uint8Array(readFileSync(resolve(dir, path))) };
71
+ }
72
+
73
+ /** upload a release directory into a voidbase instance that runs the site's control plane (superuser token) */
74
+ export async function pushRelease(o: { dir: string; url: string; token: string; activate?: boolean; log?: (line: string) => void }): Promise<{ version: string; files: number }> {
75
+ const log = o.log ?? ((l: string) => console.log(l)); const base = o.url.replace(/\/$/, "");
76
+ const manifest = JSON.parse(readFileSync(resolve(o.dir, "manifest.json"), "utf8")) as ReleaseManifest;
77
+ if (!o.token) throw new Error("a superuser token is required (voidbase release push --token, or VOIDBASE_RELEASE_TOKEN)");
78
+ const files = walk(o.dir).filter((f) => f !== "manifest.json"); files.push("manifest.json"); // the manifest last: it makes the release visible
79
+ let n = 0;
80
+ for (const f of files) {
81
+ const res = await fetch(`${base}/api/vbcloud/releases/${encodeURIComponent(manifest.version)}/files?path=${encodeURIComponent(f)}`, { method: "POST", headers: { authorization: o.token, "content-type": "application/octet-stream" }, body: readFileSync(resolve(o.dir, f)) });
82
+ if (!res.ok) throw new Error(`push ${f}: ${res.status} ${(await res.text()).slice(0, 200)}`);
83
+ n++;
84
+ }
85
+ if (o.activate !== false) { const res = await fetch(`${base}/api/vbcloud/releases/${encodeURIComponent(manifest.version)}/activate`, { method: "POST", headers: { authorization: o.token } }); if (!res.ok) throw new Error(`activate: ${res.status} ${(await res.text()).slice(0, 200)}`); }
86
+ log(`pushed release ${manifest.version} (${n} files) to ${base}${o.activate !== false ? " and activated it" : ""}`);
87
+ return { version: manifest.version, files: n };
88
+ }
@@ -0,0 +1,51 @@
1
+ // `voidbase cloud init`: the Void project that deploys ../pb_hooks and ../pb_migrations to Cloudflare Workers.
2
+ // Generated, not maintained: consumers regenerate it after upgrading voidbase (it is git-ignored in the starter).
3
+ import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { cronTriggers } from "../../hooks-plugin";
6
+ const ROOT = resolve(import.meta.dir, "../..");
7
+
8
+ // mode "package": a visible project importing the voidbase package (voidbase cloud init).
9
+ // mode "internal": a project inside this package at .cloud/<slug>, importing ../../src etc. (voidbase deploy).
10
+ export function writeCloudProject(out: string, mode: "package" | "internal" = "package", extra: { hooksDir?: string; migrationsDir?: string; entry?: string; queue?: string | false; hub?: boolean } = {}): { files: number; out: string } {
11
+ const parentPkg = existsSync("package.json") ? (JSON.parse(readFileSync("package.json", "utf8")) as { dependencies?: Record<string, string> }) : {};
12
+ const spec = parentPkg.dependencies?.["@voidbase-cloud/voidbase"] ?? parentPkg.dependencies?.voidbase ?? "^0.1.0";
13
+ const own = JSON.parse(readFileSync(`${ROOT}/package.json`, "utf8")) as { devDependencies: Record<string, string> };
14
+ const rel = (p: string) => p.replace(/\\/g, "/");
15
+ // import targets: the voidbase package by name (visible project) or this package by relative path (internal
16
+ // project at <package>/.cloud/<slug>; depth = how many directories the importing file sits below the project root)
17
+ const pkg = (depth: number, target: string, byName: string) => (mode === "package" ? byName : "../".repeat(depth + 2) + target);
18
+ const P = {
19
+ plugin: pkg(0, "hooks-plugin", "@voidbase-cloud/voidbase/plugin"), env: pkg(0, "env.ts", "@voidbase-cloud/voidbase/env"), // Void loads env.ts Node-style: the internal path needs its extension
20
+ app: pkg(2, "src/server/app", "@voidbase-cloud/voidbase/app"), cronsApp: pkg(1, "src/server/app", "@voidbase-cloud/voidbase/app"), crons: pkg(1, "src/server/crons", "@voidbase-cloud/voidbase/crons"),
21
+ schema: mode === "package" ? "@voidbase-cloud/voidbase/schema" : `${rel(ROOT)}/db/schema.ts`, // absolute: Void's drift check copies the project into .void/deploy-drift-check/<tmp>/ before drizzle-kit loads db/schema.ts
22
+ api: pkg(2, "src/server/api", "@voidbase-cloud/voidbase/api"),
23
+ jobs: pkg(1, "src/server/jobs", "@voidbase-cloud/voidbase/jobs"),
24
+ hub: pkg(0, "src/server/hub", "@voidbase-cloud/voidbase/hub"),
25
+ };
26
+ // the project's main.ts (its register(app) function) is composed into the Worker exactly as in `bun main.ts`
27
+ const entry = extra.entry ? `\nimport { appApi } from "${P.api}";\nimport { register } from ${JSON.stringify(extra.entry)};\nregister(appApi());\n` : "";
28
+ const hooksDir = JSON.stringify(extra.hooksDir ?? "../pb_hooks"); const migrationsDir = JSON.stringify(extra.migrationsDir ?? "../pb_migrations");
29
+ // the hooks' cronAdd expressions become the Worker's triggers (plus an hourly maintenance tick)
30
+ const triggers = cronTriggers(resolve(out, extra.hooksDir ?? "../pb_hooks"));
31
+ const files: Record<string, string> = {
32
+ "package.json": JSON.stringify({ name: "cloud", private: true, type: "module", scripts: { dev: "vp dev --port 8090 --host 0.0.0.0", build: "vp build", preview: "vp preview --port 8090", "panel:sync": "voidbase panel sync --dest public/_", deploy: "void deploy" }, dependencies: { "@voidbase-cloud/voidbase": spec }, devDependencies: { "@cloudflare/workers-types": own.devDependencies["@cloudflare/workers-types"], typescript: "^5.9.3", vite: own.devDependencies.vite, "vite-plus": own.devDependencies["vite-plus"], void: own.devDependencies.void } }, null, 2) + "\n",
33
+ "vite.config.ts": `import { defineConfig, loadEnv } from "vite";\nimport { voidPlugin } from "void";\nimport { pbHooksPlugin } from "${P.plugin}";\n\n// the project's pb_hooks/ and pb_migrations/ (one directory up) are bundled into the Worker\nexport default defineConfig(({ mode }) => {\n const env = loadEnv(mode, process.cwd(), "");\n return { plugins: [voidPlugin({ persistTo: env.VOIDBASE_PERSIST_TO || undefined }), pbHooksPlugin({ dir: env.VOIDBASE_HOOKS_DIR || ${hooksDir}, migrationsDir: env.VOIDBASE_MIGRATIONS_DIR || ${migrationsDir}${extra.hub !== false ? `, hubEntry: ${JSON.stringify(P.hub)}` : ""} })] };\n});\n`,
34
+ "void.json": JSON.stringify({ $schema: "./node_modules/void/schema.json", worker: { compatibility_date: "2026-09-05", compatibility_flags: ["nodejs_compat"] }, routing: { notFound: "404-page" }, inference: { bindings: { db: true, storage: true } } }, null, 2) + "\n",
35
+ "env.ts": `export { default } from "${P.env}";\n`,
36
+ "routes/api/[...path].ts": `// Every /api/* request is handled by voidbase's Hono app (PocketBase wire protocol).\nimport { defineHandler } from "void";\nimport { app } from "${P.app}";\n${entry}\nconst handle = defineHandler((c) => app.fetch(c.req.raw, c.env, (c as unknown as { executionCtx?: ExecutionContext }).executionCtx));\nexport const GET = handle; export const POST = handle; export const PATCH = handle; export const PUT = handle; export const DELETE = handle; export const OPTIONS = handle;\n`,
37
+ "crons/every-minute.ts": `// Cloudflare cron triggers: the hooks' cronAdd expressions and an hourly tick for PocketBase's maintenance.\nimport { defineScheduled } from "void";\nimport "${P.cronsApp}";\nimport { runDue } from "${P.crons}";\n\nexport const cron = ${JSON.stringify(triggers)};\nexport default defineScheduled(async (controller, env) => { await runDue(env as never, new Date(controller.scheduledTime)); });\n`,
38
+ "db/schema.ts": `// voidbase's system tables; user collections are data, as in PocketBase.\nexport * from "${P.schema}";\n`,
39
+ // the jobs queue (mail and automatic backups with retries); absent, every job runs inline
40
+ ...(extra.queue !== false ? { [`queues/${extra.queue || "jobs"}.ts`]: `// Cloudflare Queue consumer for voidbase's background jobs: outbound mail and automatic backups.\n// Its presence gives the Worker the queue producer binding (QUEUE_<NAME>); without this file every job runs\n// inline in the request. Retries: up to maxRetries with backoff, then dropped and alerted (VOIDBASE_ALERT_WEBHOOK_URL).\nimport { defineQueue } from "void";\nimport "${P.cronsApp}";\nimport { consumeJobs, type Job } from "${P.jobs}";\n\nexport const maxBatchSize = 10;\nexport const maxBatchTimeout = 1;\nexport const maxRetries = 5;\nexport const retryDelay = 30;\n\nexport default defineQueue<Job>(async (batch, env) => { await consumeJobs(batch as never, env as never, maxRetries); });\n` } : {}),
41
+ "tsconfig.json": JSON.stringify({ extends: "./.void/tsconfig.json", compilerOptions: { types: ["@cloudflare/workers-types"], strict: true, noEmit: true, moduleResolution: "bundler", module: "esnext", target: "esnext" }, include: ["routes", "crons", "queues", "db", "env.ts", "vite.config.ts"] }, null, 2) + "\n",
42
+ ".gitignore": "node_modules\ndist\n.void\n.wrangler\n.env\n.env.*\n!.env.example\npublic/*\n",
43
+ ".env.example": "# worker vars for local dev/preview of this Void project (production secrets: void secret put / wrangler secret put)\nVOIDBASE_SUPERUSER_EMAIL=admin@example.com\nVOIDBASE_SUPERUSER_PASSWORD=changeme123\nAUDITLOG=posts,users\n",
44
+ "README.md": "# cloud\n\nGenerated by `voidbase cloud init`: the Void project that deploys ../pb_hooks and ../pb_migrations to Cloudflare Workers.\n\n```bash\nbun install\nbun run panel:sync # admin panel into public/_ (copy a frontend build into public/ too, if any)\nvoid deploy # Void platform\nvoid deploy --backend cloudflare --provision # your own Cloudflare account\n```\n\nRegenerate with `voidbase cloud init` after upgrading voidbase; keep your own changes elsewhere.\n",
45
+ };
46
+ for (const [name, content] of Object.entries(files)) { mkdirSync(resolve(out, name, ".."), { recursive: true }); writeFileSync(resolve(out, name), content); }
47
+ if (extra.queue === false) rmSync(resolve(out, "queues"), { recursive: true, force: true }); // a regenerated project drops the queue it no longer has
48
+ mkdirSync(resolve(out, "db/migrations"), { recursive: true });
49
+ cpSync(`${ROOT}/db/migrations`, resolve(out, "db/migrations"), { recursive: true });
50
+ return { files: Object.keys(files).length, out };
51
+ }
package/src/node/d1.ts ADDED
@@ -0,0 +1,44 @@
1
+ /// <reference types="@cloudflare/workers-types" />
2
+ // D1Database on bun:sqlite: the subset the server uses (prepare/bind/all/first/run/raw, batch as a transaction, exec).
3
+ import { Database } from "bun:sqlite";
4
+ type Params = unknown[];
5
+ const conv = (v: unknown) => (v instanceof ArrayBuffer ? new Uint8Array(v) : v === undefined ? null : v) as never;
6
+ const isRead = (sql: string) => /^\s*(SELECT|WITH|PRAGMA|EXPLAIN)\b/i.test(sql) || /\bRETURNING\b/i.test(sql);
7
+ class Statement {
8
+ constructor(private db: Database, private sql: string, private params: Params = []) {}
9
+ bind(...values: unknown[]) { return new Statement(this.db, this.sql, values.map(conv)); }
10
+ private prep() { return this.db.prepare(this.sql); }
11
+ runSync() {
12
+ const q = this.prep();
13
+ try {
14
+ if (isRead(this.sql)) { const results = q.all(...(this.params as never[])); return { results, success: true, meta: { changes: 0, last_row_id: 0, duration: 0, rows_read: results.length, rows_written: 0 } }; }
15
+ const r = q.run(...(this.params as never[]));
16
+ return { results: [], success: true, meta: { changes: r.changes, last_row_id: Number(r.lastInsertRowid), duration: 0, rows_read: 0, rows_written: r.changes } };
17
+ } finally { q.finalize(); }
18
+ }
19
+ async all<T = Record<string, unknown>>() { return this.runSync() as unknown as D1Result<T>; }
20
+ async run<T = Record<string, unknown>>() { return this.runSync() as unknown as D1Result<T>; }
21
+ async first<T = Record<string, unknown>>(column?: string) {
22
+ const q = this.prep();
23
+ try { const row = q.get(...(this.params as never[])) as Record<string, unknown> | null; if (!row) return null; return (column ? row[column] : row) as T; } finally { q.finalize(); }
24
+ }
25
+ async raw<T = unknown[]>(opts?: { columnNames?: boolean }) {
26
+ const q = this.prep();
27
+ try { const rows = q.values(...(this.params as never[])) as unknown as T[]; return (opts?.columnNames ? [q.columnNames as unknown as T, ...rows] : rows); } finally { q.finalize(); }
28
+ }
29
+ }
30
+ export function d1(db: Database): D1Database {
31
+ const api = {
32
+ prepare: (sql: string) => new Statement(db, sql),
33
+ batch: async (statements: Statement[]) => db.transaction(() => statements.map((s) => s.runSync()))(),
34
+ exec: async (sql: string) => { const t = Date.now(); db.exec(sql); return { count: sql.split(";").filter((s) => s.trim()).length, duration: Date.now() - t }; },
35
+ dump: async () => { throw new Error("dump is not supported"); },
36
+ withSession: () => { throw new Error("sessions are not supported"); },
37
+ };
38
+ return api as unknown as D1Database;
39
+ }
40
+ export function openDatabase(path: string): Database {
41
+ const db = new Database(path, { create: true });
42
+ db.exec("PRAGMA journal_mode = WAL; PRAGMA busy_timeout = 5000; PRAGMA foreign_keys = OFF;");
43
+ return db;
44
+ }