@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,136 @@
1
+ // Core middleware parity (apis/middlewares*.go): real client IP through settings.trustedProxy, PocketBase's rate
2
+ // limit rules (labels, audiences, prefix rules, fixed windows per client) and the default 32 MB body limit.
3
+ // Counters live in isolate memory, so limits are approximate across isolates; PocketBase's are per process. On
4
+ // Cloudflare a rate-limit binding (RATE_LIMITER, declared by the deploy) adds an exact per-location ceiling per IP.
5
+ import type { Context, MiddlewareHandler } from "hono";
6
+ import { findCollection } from "./collections/model";
7
+ import { ApiError } from "./errors";
8
+ import { loadSettings, type Settings } from "./settings";
9
+ import type { AppEnv } from "./types";
10
+
11
+ const isIP = (s: string) => /^(\d{1,3}\.){3}\d{1,3}$/.test(s) || /^[0-9a-fA-F:]+$/.test(s);
12
+
13
+ // core/event_request.go RealIP: trusted proxy headers (last value, leftmost or rightmost IP), else the connection IP
14
+ export function realIPWith(settings: Settings, c: Context<AppEnv>): string {
15
+ for (const h of settings.trustedProxy.headers) {
16
+ const raw = c.req.header(h);
17
+ if (!raw) continue;
18
+ const ips = raw.split(",").map((s) => s.trim()).filter(isIP);
19
+ if (!ips.length) continue;
20
+ return settings.trustedProxy.useLeftmostIP ? ips[0]! : ips[ips.length - 1]!;
21
+ }
22
+ return c.req.header("CF-Connecting-IP") ?? c.req.header("X-Real-IP") ?? "127.0.0.1"; // the connection's own address on Workers
23
+ }
24
+ export async function realIP(c: Context<AppEnv>): Promise<string> { return realIPWith(await loadSettings(c.env.DB), c); }
25
+
26
+ export function ipInList(list: string[], ip: string): boolean {
27
+ if (!list.length || !ip) return false;
28
+ const v4 = (s: string): number | null => { const m = /^(\d+)\.(\d+)\.(\d+)\.(\d+)$/.exec(s); return m ? ((+m[1]! << 24) | (+m[2]! << 16) | (+m[3]! << 8) | +m[4]!) >>> 0 : null; };
29
+ for (const item of list) {
30
+ if (item === ip) return true;
31
+ if (!item.includes("/")) continue;
32
+ const [net, bitsStr] = item.split("/"); const bits = Number(bitsStr);
33
+ const a = v4(net ?? ""), b = v4(ip);
34
+ if (a === null || b === null) continue;
35
+ const mask = bits === 0 ? 0 : (~0 << (32 - bits)) >>> 0;
36
+ if (((a & mask) >>> 0) === ((b & mask) >>> 0)) return true;
37
+ }
38
+ return false;
39
+ }
40
+
41
+ // ---- rate limits ---------------------------------------------------------------------------------
42
+ interface Rule { label: string; audience: string; duration: number; maxRequests: number }
43
+ interface Window { start: number; count: number }
44
+ const limiters = new Map<string, Map<string, Window>>();
45
+ let lastSweep = 0;
46
+
47
+ // tags per route as bound in apis/record_auth.go, record_crud.go and file.go
48
+ const COLLECTION_ROUTES: { re: RegExp; method: string; tags: string[]; pattern: string }[] = [
49
+ { re: /^\/api\/collections\/([^/]+)\/records$/, method: "GET", tags: ["list"], pattern: "GET /api/collections/{collection}/records" },
50
+ { re: /^\/api\/collections\/([^/]+)\/records$/, method: "POST", tags: ["create"], pattern: "POST /api/collections/{collection}/records" },
51
+ { re: /^\/api\/collections\/([^/]+)\/records\/[^/]+$/, method: "GET", tags: ["view"], pattern: "GET /api/collections/{collection}/records/{id}" },
52
+ { re: /^\/api\/collections\/([^/]+)\/records\/[^/]+$/, method: "PATCH", tags: ["update"], pattern: "PATCH /api/collections/{collection}/records/{id}" },
53
+ { re: /^\/api\/collections\/([^/]+)\/records\/[^/]+$/, method: "DELETE", tags: ["delete"], pattern: "DELETE /api/collections/{collection}/records/{id}" },
54
+ { re: /^\/api\/files\/([^/]+)\/[^/]+\/[^/]+$/, method: "GET", tags: ["file"], pattern: "GET /api/files/{collection}/{recordId}/{filename}" },
55
+ { re: /^\/api\/collections\/([^/]+)\/auth-methods$/, method: "GET", tags: ["listAuthMethods"], pattern: "GET /api/collections/{collection}/auth-methods" },
56
+ { re: /^\/api\/collections\/([^/]+)\/auth-refresh$/, method: "POST", tags: ["authRefresh"], pattern: "POST /api/collections/{collection}/auth-refresh" },
57
+ { re: /^\/api\/collections\/([^/]+)\/auth-with-password$/, method: "POST", tags: ["authWithPassword", "auth"], pattern: "POST /api/collections/{collection}/auth-with-password" },
58
+ { re: /^\/api\/collections\/([^/]+)\/auth-with-oauth2$/, method: "POST", tags: ["authWithOAuth2", "auth"], pattern: "POST /api/collections/{collection}/auth-with-oauth2" },
59
+ { re: /^\/api\/collections\/([^/]+)\/request-otp$/, method: "POST", tags: ["requestOTP"], pattern: "POST /api/collections/{collection}/request-otp" },
60
+ { re: /^\/api\/collections\/([^/]+)\/auth-with-otp$/, method: "POST", tags: ["authWithOTP", "auth"], pattern: "POST /api/collections/{collection}/auth-with-otp" },
61
+ { re: /^\/api\/collections\/([^/]+)\/request-password-reset$/, method: "POST", tags: ["requestPasswordReset"], pattern: "POST /api/collections/{collection}/request-password-reset" },
62
+ { re: /^\/api\/collections\/([^/]+)\/confirm-password-reset$/, method: "POST", tags: ["confirmPasswordReset"], pattern: "POST /api/collections/{collection}/confirm-password-reset" },
63
+ { re: /^\/api\/collections\/([^/]+)\/request-verification$/, method: "POST", tags: ["requestVerification"], pattern: "POST /api/collections/{collection}/request-verification" },
64
+ { re: /^\/api\/collections\/([^/]+)\/confirm-verification$/, method: "POST", tags: ["confirmVerification"], pattern: "POST /api/collections/{collection}/confirm-verification" },
65
+ { re: /^\/api\/collections\/([^/]+)\/request-email-change$/, method: "POST", tags: ["requestEmailChange"], pattern: "POST /api/collections/{collection}/request-email-change" },
66
+ { re: /^\/api\/collections\/([^/]+)\/confirm-email-change$/, method: "POST", tags: ["confirmEmailChange"], pattern: "POST /api/collections/{collection}/confirm-email-change" },
67
+ ];
68
+
69
+ // RateLimitsConfig.FindRateLimitRule: exact label in order, prefix rules ("/api/") against the first label only
70
+ function findRule(rules: Rule[], labels: string[], audiences: string[]): Rule | null {
71
+ const prefixRules: Rule[] = [];
72
+ for (let i = 0; i < labels.length; i++) {
73
+ const label = labels[i]!;
74
+ for (const r of rules) {
75
+ if (label === r.label && audiences.includes(r.audience)) return r;
76
+ if (i === 0 && r.label.endsWith("/")) prefixRules.push(r);
77
+ }
78
+ for (const r of prefixRules) if ((label + "/").startsWith(r.label) && audiences.includes(r.audience)) return r;
79
+ }
80
+ return null;
81
+ }
82
+
83
+ function consume(limiterId: string, clientKey: string, rule: Rule): boolean {
84
+ const now = Date.now();
85
+ if (now - lastSweep > 60_000) { lastSweep = now; for (const [id, m] of limiters) { for (const [k, w] of m) if (now - w.start > 1800_000) m.delete(k); if (!m.size) limiters.delete(id); } }
86
+ let clients = limiters.get(limiterId);
87
+ if (!clients) { clients = new Map(); limiters.set(limiterId, clients); }
88
+ let w = clients.get(clientKey);
89
+ if (!w || now - w.start >= rule.duration * 1000) { w = { start: now, count: 0 }; clients.set(clientKey, w); }
90
+ w.count++;
91
+ return w.count <= rule.maxRequests;
92
+ }
93
+
94
+ export function rateLimitMiddleware(): MiddlewareHandler<AppEnv> {
95
+ return async (c, next) => {
96
+ const settings = await loadSettings(c.env.DB);
97
+ const auth = c.get("auth");
98
+ const ip = realIPWith(settings, c);
99
+ if (!settings.rateLimits.enabled || (auth && auth.collection.name === "_superusers") || ipInList(settings.rateLimits.excludedIPs, ip)) return next();
100
+ // the binding's counters are shared by every isolate in a location (its limit and period are fixed at deploy time)
101
+ if (c.env.RATE_LIMITER && !(await c.env.RATE_LIMITER.limit({ key: ip })).success) throw new ApiError(429, "Too Many Requests.", {});
102
+ const path = new URL(c.req.url).pathname;
103
+ const method = c.req.method.toUpperCase();
104
+ const audiences = auth ? ["", "@auth"] : ["", "@guest"];
105
+ const rules = settings.rateLimits.rules as Rule[];
106
+ const route = COLLECTION_ROUTES.find((r) => r.method === method && r.re.test(path));
107
+ let rule: Rule | null = null; let limiterId = "";
108
+ if (route) {
109
+ const collName = route.re.exec(path)![1]!;
110
+ const collection = await findCollection(c.env.DB, collName);
111
+ if (!collection) throw new ApiError(404, "Missing or invalid collection context.", {});
112
+ const labels = [...route.tags.map((t) => `${collection.name}:${t}`), ...route.tags.map((t) => `*:${t}`), `${method} ${path}`, path];
113
+ rule = findRule(rules, labels, audiences);
114
+ if (rule) limiterId = collection.id + route.pattern + route.tags.join("") + rule.audience;
115
+ } else {
116
+ rule = findRule(rules, [`${method} ${path}`, path], audiences);
117
+ if (rule) limiterId = rule.label + rule.audience;
118
+ }
119
+ if (rule) {
120
+ if (rule.audience === "@guest" && auth) return next();
121
+ if (rule.audience === "@auth" && !auth) return next();
122
+ if (!consume(limiterId, ip, rule)) throw new ApiError(429, "Too Many Requests.", {});
123
+ }
124
+ return next();
125
+ };
126
+ }
127
+
128
+ // apis/middlewares_body_limit.go: 32 MB unless a route says otherwise (batch inlines its own limit)
129
+ const DEFAULT_MAX_BODY = 32 << 20;
130
+ export function bodyLimitMiddleware(): MiddlewareHandler<AppEnv> {
131
+ return async (c, next) => {
132
+ const len = Number(c.req.header("content-length") ?? 0);
133
+ if (len > DEFAULT_MAX_BODY && !new URL(c.req.url).pathname.startsWith("/api/batch") && !new URL(c.req.url).pathname.startsWith("/api/backups/upload")) throw new ApiError(413, "Request entity too large.", {});
134
+ return next();
135
+ };
136
+ }
@@ -0,0 +1,147 @@
1
+ // Loads the bundled pb_hooks files, exposes the JSVM-compatible globals, mounts routerAdd routes.
2
+ import { logger } from "#platform/log";
3
+ import type { Hono, MiddlewareHandler } from "hono";
4
+ import { files, hooks, hooksDir, modules } from "#platform/hooks";
5
+ import { loadCollections } from "../collections/model";
6
+ import { loadSettings } from "../settings";
7
+ import type { AppEnv } from "../types";
8
+ import { CollectionRef, HookRecord } from "./record";
9
+ import {
10
+ $apis, $app, $dbx, $filesystem, $http, $security, BadRequestError, ForbiddenError, InternalServerError, MailerMessage, NotFoundError,
11
+ RecordUpsertFormFactory, RequestEvent, UnauthorizedError, ValidationError, authToHookRecord, cronAdd, cronRemove, hookStore,
12
+ crons, eventHooks, makeOs, onEvent, routerAdd, routerUse, routes, type HookMiddleware,
13
+ } from "./runtime";
14
+ import { ApiError } from "../errors";
15
+
16
+ const HOOKS_PREFIX = "/pb_hooks";
17
+ const $os = makeOs(files, HOOKS_PREFIX);
18
+ const moduleCache = new Map<string, unknown>();
19
+
20
+ class DateTime { d: Date; constructor(v?: string | number | Date) { this.d = v === undefined ? new Date() : new Date(v); } string() { return this.d.toISOString().replace("T", " "); } time() { return this.d; } unix() { return Math.floor(this.d.getTime() / 1000); } toJSON() { return this.string(); } }
21
+
22
+ // new Field({...}) / new TextField({...}) in JSVM code produce plain field data
23
+ function fieldClass(type?: string) {
24
+ return class { constructor(data: Record<string, unknown> = {}) { return { ...(type ? { type } : {}), ...data }; } };
25
+ }
26
+
27
+ function buildGlobals(): Record<string, unknown> {
28
+ const g: Record<string, unknown> = {
29
+ $app, $apis, $http, $os, $filesystem, $security,
30
+ $mails: {}, $template: { loadFiles: () => ({ render: () => "" }) }, $dbx,
31
+ routerAdd, routerUse, cronAdd, cronRemove,
32
+ migrate: () => { /* migrations are applied by the migrations runner, not at hook load */ },
33
+ Record: class Record extends HookRecord { constructor(collection: CollectionRef, data?: { [k: string]: unknown }) { super(collection, data ?? {}); } },
34
+ Collection: CollectionRef,
35
+ Field: fieldClass(), TextField: fieldClass("text"), EditorField: fieldClass("editor"), NumberField: fieldClass("number"), BoolField: fieldClass("bool"),
36
+ EmailField: fieldClass("email"), URLField: fieldClass("url"), DateField: fieldClass("date"), AutodateField: fieldClass("autodate"), SelectField: fieldClass("select"),
37
+ FileField: fieldClass("file"), RelationField: fieldClass("relation"), JSONField: fieldClass("json"), GeoPointField: fieldClass("geoPoint"), PasswordField: fieldClass("password"),
38
+ RecordUpsertForm: RecordUpsertFormFactory($app),
39
+ MailerMessage, DateTime, RequestInfo: class {},
40
+ ApiError, NotFoundError, BadRequestError, ForbiddenError, UnauthorizedError, InternalServerError, ValidationError,
41
+ __hooks: HOOKS_PREFIX,
42
+ console,
43
+ toString: (v: unknown) => String(v), sleep: (ms: number) => new Promise((r) => setTimeout(r, ms)), arrayOf: () => [], unmarshal: (v: unknown, dst: unknown) => Object.assign(dst as object, v as object),
44
+ module: { exports: {} }, exports: {},
45
+ require: (path: string) => requireModule(path),
46
+ };
47
+ for (const name of Object.keys(g)) void name;
48
+ // event hook registration functions: onRecordCreate(fn, ...tags) etc.
49
+ const proxy = new Proxy(g, {
50
+ get(target, prop: string) {
51
+ if (prop in target) return target[prop];
52
+ if (typeof prop === "string" && prop.startsWith("on")) return (fn: (e: unknown) => unknown, ...tags: string[]) => onEvent(prop, fn, tags);
53
+ return undefined;
54
+ },
55
+ });
56
+ return proxy;
57
+ }
58
+
59
+ function requireModule(path: string): unknown {
60
+ const rel = path.replace(/^\.\//, "").replace(new RegExp("^" + HOOKS_PREFIX + "/?"), "").replace(/\.js$/, "");
61
+ if (moduleCache.has(rel)) return moduleCache.get(rel);
62
+ const factory = modules[rel];
63
+ if (!factory) throw new Error(`Cannot find module '${path}'`);
64
+ const module = { exports: {} as Record<string, unknown> };
65
+ const target: Record<string, unknown> = { ...Object.fromEntries(Object.entries(GLOBALS)), module, exports: module.exports };
66
+ const g = new Proxy(target, {
67
+ get(t, prop: string) { return prop in t ? t[prop] : (GLOBALS as Record<string, unknown>)[prop]; },
68
+ });
69
+ const result = factory(g);
70
+ // module bodies rarely await at the top level; when they do, the promise resolves to module.exports
71
+ const exported = result instanceof Promise ? module.exports : (result ?? module.exports);
72
+ moduleCache.set(rel, exported);
73
+ return exported;
74
+ }
75
+
76
+ const GLOBALS = buildGlobals();
77
+ export const hookGlobals = () => GLOBALS;
78
+
79
+ let loaded = false;
80
+ export function loadHooks() {
81
+ if (loaded) return;
82
+ loaded = true;
83
+ // a dev reload re-evaluates this module while runtime.ts keeps its registries: start from empty
84
+ routes.length = 0;
85
+ eventHooks.clear();
86
+ crons.clear();
87
+ for (const h of hooks) {
88
+ try {
89
+ // top-level registrations run synchronously inside run(); the returned promise only settles handlers
90
+ void h.run(GLOBALS).catch((err) => logger.error("voidbase: hook file failed", { hook: h.name, error: err instanceof Error ? `${err.name}: ${err.message}` : String(err) }));
91
+ } catch (err) { logger.error("voidbase: hook file failed", { hook: h.name, error: err instanceof Error ? `${err.name}: ${err.message}` : String(err) }); }
92
+ }
93
+ console.log(`voidbase: loaded ${hooks.length} hook file(s) from ${hooksDir}, ${routes.length} route(s)`);
94
+ }
95
+
96
+ // Runs handlers registered with routerAdd. Registered after the core routes so PocketBase's own API wins.
97
+ // Hook routes (routerAdd from pb_hooks or a project's main.ts) are served by one catch-all registered after the core
98
+ // routes, matching against the live registry at request time. Hono builds its matcher on the first request and
99
+ // ignores routes added afterwards, so registrations may happen at any time (a main.ts composes after the JS hooks).
100
+ interface Compiled { route: (typeof routes)[number]; re: RegExp; keys: string[]; score: number }
101
+ let compiled: Compiled[] | null = null; let compiledFor = -1;
102
+ function compile(): Compiled[] {
103
+ if (compiled && compiledFor === routes.length) return compiled;
104
+ // Go's ServeMux picks the most specific pattern: literal segments beat params beat wildcards
105
+ const score = (p: string) => (p.includes("*") ? 0 : 1000) + p.split("/").filter((s) => s && !s.startsWith(":")).length * 10 + p.split("/").length;
106
+ compiled = routes.map((route) => {
107
+ const keys: string[] = [];
108
+ const src = route.path.split("/").map((seg) => {
109
+ if (seg === "*") return ".*";
110
+ if (seg.startsWith(":")) { keys.push(seg.slice(1)); return "([^/]+)"; }
111
+ return seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
112
+ }).join("/");
113
+ return { route, re: new RegExp(`^${src}/?$`), keys, score: score(route.path) };
114
+ }).sort((a, b) => b.score - a.score);
115
+ compiledFor = routes.length;
116
+ return compiled;
117
+ }
118
+ export function mountHookRoutes(app: Hono<AppEnv>) {
119
+ app.all("*", async (c) => {
120
+ const path = new URL(c.req.url).pathname;
121
+ for (const { route, re, keys } of compile()) {
122
+ if (route.method !== "ALL" && route.method !== c.req.method) continue;
123
+ const m = re.exec(path); if (!m) continue;
124
+ const ev = new RequestEvent(c, authToHookRecord(c.get("auth")));
125
+ keys.forEach((k, i) => { ev.params[k] = decodeURIComponent(m[i + 1] ?? ""); });
126
+ const chain: HookMiddleware[] = [...route.middlewares, route.handler];
127
+ let i = 0;
128
+ const next = async (): Promise<unknown> => { const mw = chain[i++]; if (!mw) return undefined; return (typeof mw === "function" ? mw : mw.func)(ev); };
129
+ ev.next = next;
130
+ const result = await next();
131
+ if (result instanceof Response) return result;
132
+ if (ev.written) return ev.written;
133
+ return c.body(null, 204);
134
+ }
135
+ return c.notFound();
136
+ });
137
+ }
138
+
139
+ // Per-request state for $app and friends.
140
+ export function hookMiddleware(): MiddlewareHandler<AppEnv> {
141
+ return async (c, next) => {
142
+ const collections = await loadCollections(c.env.DB);
143
+ const settings = await loadSettings(c.env.DB);
144
+ const { recordContextFor } = await import("../app");
145
+ return hookStore.run({ c, ctx: () => recordContextFor(c), collections, settings, env: c.env as unknown as Record<string, unknown> }, next);
146
+ };
147
+ }
@@ -0,0 +1,58 @@
1
+ // PocketBase-style JS migrations (pb_migrations/*.js): `migrate(up, down)` files applied once at bootstrap,
2
+ // tracked in _pbMigrations (file, applied). The `app` handed to `up` is $app plus importCollections, so the
3
+ // starter's snapshot migration creates its schema on a fresh database and later migrations can use
4
+ // findCollectionByNameOrId / save / delete like they do in PocketBase.
5
+ import { migrations } from "#platform/migrations";
6
+ import { invalidateCollections, loadCollections } from "../collections/model";
7
+ import { importCollections } from "../collections/service";
8
+ import { all, stmt } from "../db";
9
+ import { loadSettings } from "../settings";
10
+ import type { RecordContext } from "../records/service";
11
+ import type { AppEnv } from "../types";
12
+ import { hookStore } from "./runtime";
13
+
14
+ export type MigrationFn = (app: Record<string, unknown>) => unknown;
15
+
16
+ // Runs fn inside a superuser hook store outside any request (migrations, cron jobs), so $app works as in a request.
17
+ export async function withHookStore<T>(db: D1Database, bindings: AppEnv["Bindings"] | undefined, fn: () => Promise<T> | T): Promise<T> {
18
+ const collections = await loadCollections(db);
19
+ const ctx = async (): Promise<RecordContext> => ({
20
+ db, storage: bindings?.STORAGE as R2Bucket, auth: null, superuser: true,
21
+ request: { auth: null, method: "GET", query: {}, headers: {}, body: {}, context: "default" },
22
+ collections: await loadCollections(db),
23
+ });
24
+ const store = { c: undefined as never, ctx, collections, settings: await loadSettings(db), env: (bindings ?? {}) as Record<string, unknown> };
25
+ return hookStore.run(store, () => fn());
26
+ }
27
+
28
+ export async function applyPendingMigrations(db: D1Database, globals: Record<string, unknown> = {}, bindings?: AppEnv["Bindings"]): Promise<string[]> {
29
+ if (!migrations.length) return [];
30
+ return withHookStore(db, bindings, () => runPending(db, globals));
31
+ }
32
+
33
+ async function runPending(db: D1Database, globals: Record<string, unknown>): Promise<string[]> {
34
+ const applied = new Set((await all<{ file: string }>(db, "SELECT file FROM `_pbMigrations`")).map((r) => r.file));
35
+ const own: Record<string, unknown> = {
36
+ importCollections: (list: Record<string, unknown>[], deleteMissing = false) => importCollections(db, list, deleteMissing),
37
+ };
38
+ const $app = (globals.$app ?? {}) as Record<string, unknown>;
39
+ const app = new Proxy(own, { get: (t, k) => (k in t ? t[k as string] : $app[k as string]), has: (t, k) => k in t || k in $app });
40
+ const done: string[] = [];
41
+ for (const m of [...migrations].sort((a, b) => a.name.localeCompare(b.name))) {
42
+ if (applied.has(m.name)) continue;
43
+ let up: MigrationFn | null = null;
44
+ const migrate = (u: MigrationFn, _down?: MigrationFn) => { up = u; };
45
+ try {
46
+ await m.run({ ...globals, migrate });
47
+ if (up) await (up as MigrationFn)(app);
48
+ } catch (err) {
49
+ console.error(`voidbase: migration ${m.name} failed:`, err instanceof Error ? err.message : err);
50
+ throw err;
51
+ }
52
+ invalidateCollections();
53
+ await stmt(db, "INSERT OR IGNORE INTO `_pbMigrations` (file, applied) VALUES (?, ?)", [m.name, Date.now()]).run();
54
+ done.push(m.name);
55
+ }
56
+ if (done.length) console.log(`voidbase: applied ${done.length} pb_migrations: ${done.join(", ")}`);
57
+ return done;
58
+ }
@@ -0,0 +1,7 @@
1
+ // Minimal typing for the Workers-provided node:async_hooks (nodejs_compat).
2
+ declare module "node:async_hooks" {
3
+ export class AsyncLocalStorage<T> {
4
+ run<R>(store: T, fn: () => R): R;
5
+ getStore(): T | undefined;
6
+ }
7
+ }
@@ -0,0 +1,152 @@
1
+ // JSVM-compatible Record / Collection wrappers used by hook code ($app, Record, RecordUpsertForm).
2
+ import { isMultiple, type Field } from "../collections/fields";
3
+ import type { Collection } from "../collections/model";
4
+ import { recordToJSON } from "../records/json";
5
+ import { fromColumn, normalizeInput, type Upload } from "../records/values";
6
+ import type { Row } from "../types";
7
+
8
+ // Field list with the JSVM helpers generated migrations use (collection.fields.addAt(...), removeById(...)).
9
+ export type FieldList = Field[] & {
10
+ add(...fields: Field[]): void; addAt(index: number, ...fields: Field[]): void;
11
+ removeById(id: string): void; removeByName(name: string): void;
12
+ getById(id: string): Field | undefined; getByName(name: string): Field | undefined;
13
+ };
14
+ export function fieldList(fields: Field[]): FieldList {
15
+ const list = fields as FieldList;
16
+ if (typeof list.addAt === "function") return list;
17
+ Object.defineProperties(list, {
18
+ add: { value(...fs: Field[]) { list.push(...fs); } },
19
+ addAt: { value(index: number, ...fs: Field[]) { list.splice(Math.max(0, Math.min(index, list.length)), 0, ...fs); } },
20
+ removeById: { value(id: string) { const i = list.findIndex((f) => f.id === id); if (i >= 0) list.splice(i, 1); } },
21
+ removeByName: { value(name: string) { const i = list.findIndex((f) => f.name === name); if (i >= 0) list.splice(i, 1); } },
22
+ getById: { value(id: string) { return list.find((f) => f.id === id); } },
23
+ getByName: { value(name: string) { return list.find((f) => f.name === name); } },
24
+ });
25
+ return list;
26
+ }
27
+
28
+ // `new Collection({...})` in hooks and migrations, and what $app.findCollectionByNameOrId returns.
29
+ // Property reads and writes fall through to the underlying collection data (unmarshal({...}, collection) works).
30
+ export class CollectionRef {
31
+ readonly data: Collection;
32
+ constructor(data: Collection | Record<string, unknown> = {}) {
33
+ const d = { fields: [], indexes: [], ...(data as Record<string, unknown>) } as unknown as Collection;
34
+ d.fields = fieldList([...(d.fields as Field[])].map((f) => ({ ...f })));
35
+ this.data = d;
36
+ return new Proxy(this, {
37
+ get(target, prop, receiver) {
38
+ if (prop in target) return Reflect.get(target, prop, receiver);
39
+ return (target.data as unknown as Record<string | symbol, unknown>)[prop];
40
+ },
41
+ set(target, prop, value) {
42
+ if (prop in target && typeof prop === "string" && !["id", "name", "type", "system", "fields"].includes(prop)) return Reflect.set(target, prop, value);
43
+ (target.data as unknown as Record<string | symbol, unknown>)[prop] = prop === "fields" ? fieldList(value as Field[]) : value;
44
+ return true;
45
+ },
46
+ has(target, prop) { return prop in target || prop in (target.data as object); },
47
+ });
48
+ }
49
+ get id() { return this.data.id; }
50
+ set id(v: string) { this.data.id = v; }
51
+ get name() { return this.data.name; }
52
+ set name(v: string) { this.data.name = v; }
53
+ get type() { return this.data.type; }
54
+ set type(v: string) { (this.data as { type: string }).type = v; }
55
+ get fields(): FieldList { return fieldList(this.data.fields as Field[]); }
56
+ set fields(v: Field[]) { this.data.fields = fieldList(v); }
57
+ get system() { return this.data.system; }
58
+ isAuth() { return this.data.type === "auth"; }
59
+ isView() { return this.data.type === "view"; }
60
+ isBase() { return this.data.type === "base"; }
61
+ // plain JSON for the collections service
62
+ toRaw(): Record<string, unknown> { return JSON.parse(JSON.stringify(this.data)) as Record<string, unknown>; }
63
+ }
64
+
65
+ export class HookRecord {
66
+ values: Record<string, unknown>;
67
+ private originalValues: Record<string, unknown>;
68
+ private hidden = new Set<string>();
69
+ private emailVisible = false;
70
+ uploads: Record<string, Upload[]> = {};
71
+ isNewRecord: boolean;
72
+
73
+ constructor(public readonly coll: CollectionRef, data: Record<string, unknown> = {}, opts: { fromRow?: boolean; isNew?: boolean } = {}) {
74
+ this.values = {};
75
+ this.originalValues = {};
76
+ for (const f of this.fields()) {
77
+ this.values[f.name] = opts.fromRow ? fromColumn(f, data[f.name]) : this.empty(f);
78
+ if (f.hidden) this.hidden.add(f.name);
79
+ }
80
+ this.isNewRecord = opts.isNew ?? !opts.fromRow;
81
+ if (opts.fromRow) this.originalValues = { ...this.values };
82
+ if (!opts.fromRow) this.load(data);
83
+ if (opts.fromRow && data.password !== undefined) this.values.password = data.password; // keep the hash for auth checks
84
+ }
85
+
86
+ static fromRow(coll: Collection, row: Row) { return new HookRecord(new CollectionRef(coll), row, { fromRow: true }); }
87
+
88
+ private fields() { return this.coll.data.fields as Field[]; }
89
+ private empty(f: Field): unknown {
90
+ if (f.type === "bool") return false;
91
+ if (f.type === "number") return 0;
92
+ if (f.type === "json") return null;
93
+ if (f.type === "geoPoint") return { lon: 0, lat: 0 };
94
+ return isMultiple(f) ? [] : "";
95
+ }
96
+
97
+ get id() { return String(this.values.id ?? ""); }
98
+ set id(v: string) { this.values.id = v; }
99
+ collection() { return this.coll; }
100
+ isNew() { return this.isNewRecord; }
101
+ original() { const r = new HookRecord(this.coll, {}, { isNew: this.isNewRecord }); r.values = { ...this.originalValues }; r.originalValues = { ...this.originalValues }; return r; }
102
+ fresh() { const r = new HookRecord(this.coll, {}, { isNew: this.isNewRecord }); r.values = { ...this.values }; r.originalValues = { ...this.originalValues }; return r; }
103
+ clone() { return this.fresh(); }
104
+ setOriginal(values: Record<string, unknown>) { this.originalValues = { ...values }; }
105
+ markAsNew() { this.isNewRecord = true; }
106
+ markAsNotNew() { this.isNewRecord = false; }
107
+
108
+ load(data: Record<string, unknown>) { for (const [k, v] of Object.entries(data ?? {})) this.set(k, v); }
109
+ get(name: string): unknown { return this.values[name]; }
110
+ set(name: string, value: unknown) {
111
+ const f = this.fields().find((x) => x.name === name);
112
+ if (!f) { this.values[name] = value; return; }
113
+ if (f.type === "file") {
114
+ const list = Array.isArray(value) ? value : value == null || value === "" ? [] : [value];
115
+ const names: string[] = [];
116
+ for (const item of list) {
117
+ if (item && typeof item === "object" && "bytes" in (item as object)) { const up = item as Upload; (this.uploads[name] ??= []).push(up); names.push(up.name); }
118
+ else if (typeof item === "string") names.push(item);
119
+ }
120
+ this.values[name] = isMultiple(f) ? names : (names[names.length - 1] ?? "");
121
+ return;
122
+ }
123
+ if (f.type === "password") { this.values[name] = value == null ? "" : String(value); return; }
124
+ this.values[name] = normalizeInput(f, value);
125
+ }
126
+ getString(name: string) { const v = this.values[name]; return v == null ? "" : Array.isArray(v) ? String(v[0] ?? "") : typeof v === "object" ? JSON.stringify(v) : String(v); }
127
+ getBool(name: string) { return !!this.values[name]; }
128
+ getInt(name: string) { return Math.trunc(Number(this.values[name]) || 0); }
129
+ getFloat(name: string) { return Number(this.values[name]) || 0; }
130
+ getStringSlice(name: string) { const v = this.values[name]; return Array.isArray(v) ? v.map(String) : v ? [String(v)] : []; }
131
+ getDateTime(name: string) { return this.getString(name); }
132
+ getRaw(name: string) { return this.values[name]; }
133
+ email() { return this.getString("email"); }
134
+ verified() { return this.getBool("verified"); }
135
+ tokenKey() { return this.getString("tokenKey"); }
136
+ isSuperuser() { return this.coll.name === "_superusers"; }
137
+ ignoreEmailVisibility(v = true) { this.emailVisible = v; return this; }
138
+ hide(...names: string[]) { for (const n of names) this.hidden.add(n); return this; }
139
+ unhide(...names: string[]) { for (const n of names) this.hidden.delete(n); return this; }
140
+ hiddenFields(): string[] { return [...this.hidden]; }
141
+ expand: Record<string, unknown> | null = null; // set by $apis.enrichRecord / $app.expandRecord
142
+ fieldsData() { return { ...this.values }; }
143
+ // JSON export as the API would return it (email included when visibility is ignored)
144
+ publicExport(): Record<string, unknown> {
145
+ const row: Row = {};
146
+ for (const f of this.fields()) row[f.name] = this.values[f.name];
147
+ const out = recordToJSON(this.coll.data, row, { own: this.emailVisible, expand: this.expand ?? undefined });
148
+ for (const n of this.hidden) delete out[n];
149
+ return out;
150
+ }
151
+ toJSON() { return this.publicExport(); }
152
+ }