@voidbase-cloud/voidbase 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +9 -0
- package/CHANGELOG.md +19 -0
- package/COMPAT.md +43 -0
- package/LICENSE +21 -0
- package/NOTICE +8 -0
- package/README.md +124 -0
- package/bin/voidbase.ts +158 -0
- package/crons/every-minute.ts +13 -0
- package/db/migrations/20260905175935_large_swarm.sql +87 -0
- package/db/migrations/20260905185720_wild_sunspot.sql +16 -0
- package/db/migrations/20260905190723_solid_toro.sql +1 -0
- package/db/migrations/20260905213340_remarkable_union_jack.sql +11 -0
- package/db/migrations/meta/20260905175935_snapshot.json +599 -0
- package/db/migrations/meta/20260905185720_snapshot.json +703 -0
- package/db/migrations/meta/20260905190723_snapshot.json +710 -0
- package/db/migrations/meta/20260905213340_snapshot.json +781 -0
- package/db/migrations/meta/_journal.json +34 -0
- package/db/schema.ts +130 -0
- package/docs/deploy.md +153 -0
- package/docs/differences.md +88 -0
- package/docs/hooks.md +84 -0
- package/docs/migrating.md +29 -0
- package/docs/perf.md +53 -0
- package/docs/platform.md +208 -0
- package/docs/releasing.md +38 -0
- package/env.ts +23 -0
- package/hooks-plugin.ts +237 -0
- package/package.json +134 -0
- package/queues/jobs.ts +13 -0
- package/routes/api/[...path].ts +19 -0
- package/scripts/bench-realtime.ts +46 -0
- package/scripts/bench.ts +39 -0
- package/scripts/ci-suites.sh +27 -0
- package/scripts/dev.sh +29 -0
- package/scripts/export.ts +70 -0
- package/scripts/seed-app-user.sh +14 -0
- package/scripts/seed-d1.ts +17 -0
- package/scripts/seed-reference.sh +29 -0
- package/scripts/starter.sh +22 -0
- package/scripts/sync-app.ts +22 -0
- package/scripts/sync-panel.ts +66 -0
- package/src/cloud/rest.ts +297 -0
- package/src/node/assets.ts +22 -0
- package/src/node/bundle.ts +88 -0
- package/src/node/cloud-init.ts +51 -0
- package/src/node/d1.ts +44 -0
- package/src/node/deploy-cf.ts +179 -0
- package/src/node/index.ts +5 -0
- package/src/node/panel.ts +21 -0
- package/src/node/serve.ts +125 -0
- package/src/node/storage.ts +51 -0
- package/src/platform/node/env.ts +4 -0
- package/src/platform/node/hooks.ts +19 -0
- package/src/platform/node/log.ts +7 -0
- package/src/platform/node/migrations.ts +5 -0
- package/src/platform/node/photon.ts +1 -0
- package/src/platform/node/sockets.ts +22 -0
- package/src/platform/node/sse.ts +23 -0
- package/src/platform/workers/env.ts +3 -0
- package/src/platform/workers/hooks.ts +2 -0
- package/src/platform/workers/log.ts +1 -0
- package/src/platform/workers/migrations.ts +1 -0
- package/src/platform/workers/photon.ts +1 -0
- package/src/platform/workers/sockets.ts +3 -0
- package/src/platform/workers/sse.ts +1 -0
- package/src/server/api.ts +27 -0
- package/src/server/app.ts +582 -0
- package/src/server/auth-extra.ts +113 -0
- package/src/server/auth-flows.ts +186 -0
- package/src/server/auth-response.ts +111 -0
- package/src/server/auth.ts +187 -0
- package/src/server/backups.ts +234 -0
- package/src/server/batch.ts +123 -0
- package/src/server/bootstrap.ts +71 -0
- package/src/server/collections/auth-option-shape.json +71 -0
- package/src/server/collections/ddl.ts +127 -0
- package/src/server/collections/fields.ts +120 -0
- package/src/server/collections/model.ts +185 -0
- package/src/server/collections/oauth2-providers.json +1 -0
- package/src/server/collections/scaffolds.json +210 -0
- package/src/server/collections/service.ts +392 -0
- package/src/server/collections/system.json +605 -0
- package/src/server/collections/system.ts +19 -0
- package/src/server/collections/validate.ts +239 -0
- package/src/server/crc32.ts +13 -0
- package/src/server/crons.ts +100 -0
- package/src/server/crypto.ts +26 -0
- package/src/server/db.ts +37 -0
- package/src/server/errors.ts +53 -0
- package/src/server/files-api.ts +52 -0
- package/src/server/filter/compile.ts +420 -0
- package/src/server/filter/lexer.ts +107 -0
- package/src/server/filter/parser.ts +49 -0
- package/src/server/hardening.ts +136 -0
- package/src/server/hooks/index.ts +147 -0
- package/src/server/hooks/migrations.ts +58 -0
- package/src/server/hooks/node-async-hooks.d.ts +7 -0
- package/src/server/hooks/record.ts +152 -0
- package/src/server/hooks/runtime.ts +344 -0
- package/src/server/hooks/virtual-migrations.d.ts +4 -0
- package/src/server/hooks/virtual.d.ts +7 -0
- package/src/server/hub.ts +91 -0
- package/src/server/ids.ts +22 -0
- package/src/server/jobs.ts +84 -0
- package/src/server/jwt.ts +61 -0
- package/src/server/logs.ts +144 -0
- package/src/server/mail/index.ts +99 -0
- package/src/server/mail/message.ts +43 -0
- package/src/server/mail/smtp.ts +82 -0
- package/src/server/mail/templates.ts +168 -0
- package/src/server/oauth2/index.ts +198 -0
- package/src/server/oauth2/providers.ts +153 -0
- package/src/server/password.ts +17 -0
- package/src/server/realtime/hub-client.ts +50 -0
- package/src/server/realtime/index.ts +239 -0
- package/src/server/records/expand.ts +129 -0
- package/src/server/records/files.ts +69 -0
- package/src/server/records/json.ts +23 -0
- package/src/server/records/picker.ts +80 -0
- package/src/server/records/service.ts +598 -0
- package/src/server/records/thumbs.ts +148 -0
- package/src/server/records/values.ts +295 -0
- package/src/server/settings-api.ts +104 -0
- package/src/server/settings.ts +215 -0
- package/src/server/sql.ts +61 -0
- package/src/server/static.ts +17 -0
- package/src/server/storage/s3.ts +118 -0
- package/src/server/types.ts +25 -0
- package/src/server/webauthn.ts +168 -0
- package/tsconfig.json +36 -0
- package/tsconfig.node.json +27 -0
- package/types/pb_data.d.ts +24438 -0
- package/vite.config.ts +10 -0
- package/void.json +12 -0
package/docs/platform.md
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# Cheap, fast, idle-free: how voidbase should use Cloudflare
|
|
2
|
+
|
|
3
|
+
One voidbase app is one Worker, one D1 database and one R2 bucket, deployed with `voidbase deploy`. That shape is
|
|
4
|
+
right and stays: Cloudflare bills Workers per request, D1 per row and R2 per byte, so an app nobody uses costs
|
|
5
|
+
its storage and nothing else, and the account limit of 500 Workers (raisable) is where "thousands of apps" is
|
|
6
|
+
bounded. The improvements below make each app cheaper per request and faster, in the order of their effect.
|
|
7
|
+
Numbers come from Cloudflare's pricing and limits pages (Workers Paid plan, 2026); the Void guides for static
|
|
8
|
+
assets, live, WebSockets, queues, KV and SSE frame what Void exposes today.
|
|
9
|
+
|
|
10
|
+
## Where the money and the milliseconds go today
|
|
11
|
+
|
|
12
|
+
| Per request or per event | Cost driver | Today |
|
|
13
|
+
| --- | --- | --- |
|
|
14
|
+
| Every asset request (panel, app bundle, images) | a Worker invocation ($0.30 per million beyond 10 million) plus CPU | was: `middleware/` made Void route **everything** worker-first (`run_worker_first: ['/**']`); now asset-first, the Worker runs for `/api` only (item 1) |
|
|
15
|
+
| Every API request | one `_logs` row written ($1 per million rows) | request logging at `minLevel: 0` writes a row per request; on a 10 million-request app that is $10 of log writes, more than the requests themselves |
|
|
16
|
+
| Every record write | one extra `_changes` row for realtime | written whether or not anyone is subscribed |
|
|
17
|
+
| Every minute, every app | a cron invocation (a billable request + CPU) | 43,200 invocations per app per month even when idle; 500 apps make 21.6 million, above the included 10 million |
|
|
18
|
+
| Every request | several D1 round trips from wherever the Worker runs | D1 lives in one region; a Worker far from it pays 50 to 150 ms per query |
|
|
19
|
+
| Every cold start | the Photon wasm is imported at module load | paid on requests that never touch an image |
|
|
20
|
+
| While a client is connected | one D1 read per second per isolate (realtime poll) | $0.003 per isolate-month of reads: already cheap, but ~1 s latency |
|
|
21
|
+
|
|
22
|
+
## Improvements, ranked by effect
|
|
23
|
+
|
|
24
|
+
1. **Assets off the Worker.** Serve the panel, the frontend build and the app's deep links from Cloudflare's asset
|
|
25
|
+
layer with the Worker scoped to `/api`. Void does this for apps with only `/api` routes (`run_worker_first` on `/api`
|
|
26
|
+
and `/api/*`), so the `middleware/` file that emulated PocketBase's `--publicDir` fallback is gone. The catch: Void's
|
|
27
|
+
generated Worker entry passes every GET 404 through `env.ASSETS.fetch`, and Cloudflare applies `not_found_handling`
|
|
28
|
+
to binding fetches too, so with the inferred `single-page-application` mode an unknown `/api/...` path came back as
|
|
29
|
+
`index.html` with status 200 (in `vp preview` and on a deployed probe). A `_redirects` `/* / 200` rule is worse: it
|
|
30
|
+
shadows real assets. The shape that works is `routing.notFound: "404-page"` with `404.html` copies of the SPA shell
|
|
31
|
+
and of the panel's index (written by `hooks-plugin.ts` at build time and by the sync scripts for dev): the asset
|
|
32
|
+
layer answers deep links itself with the shell (status 404, the pattern SvelteKit documents for Cloudflare), the
|
|
33
|
+
binding returns a real 404 for `/api` misses so API clients keep PocketBase's JSON 404, and browser navigations to
|
|
34
|
+
an unknown `/api` URL get the HTML page. Verified on a deployed probe: misses never invoke the Worker, real assets
|
|
35
|
+
keep their ETags, nested `_/404.html` wins for `/_/...`. docs/differences.md records the status-code difference.
|
|
36
|
+
2. **Log writes are the biggest D1 cost.** Default `settings.logs.minLevel` to warnings on Cloudflare (4xx, 5xx,
|
|
37
|
+
slow requests), keep the full log opt-in from the panel, and offer a sink that is built for this volume:
|
|
38
|
+
Workers Logs or Analytics Engine ($0.25 per million data points, queryable in the dashboard) instead of D1 rows.
|
|
39
|
+
PocketBase writes every request to its own SQLite file; on D1 every one of those is a billed row.
|
|
40
|
+
3. **Change-feed rows only when someone listens.** Skip the `_changes` insert when `_realtime_clients` is empty
|
|
41
|
+
(cached per isolate for a few seconds). Idle apps and batch imports halve their write rows.
|
|
42
|
+
4. **Crons only when needed.** The hooks bundle is built at deploy time, so the plugin knows every `cronAdd`
|
|
43
|
+
expression: register those as the Worker's triggers (PocketBase's cron syntax is Cloudflare's; up to 5 per
|
|
44
|
+
Worker, else fall back to every minute) plus one hourly trigger for PocketBase's maintenance jobs. Better still,
|
|
45
|
+
make the maintenance lazy (run on the next request when overdue) so an app with no `cronAdd` has no trigger.
|
|
46
|
+
5. **Latency: put the Worker next to D1, or replicate reads.** Smart Placement (`placement: { mode: "smart" }`)
|
|
47
|
+
moves each app's Worker beside its database, turning five sequential queries from 500 ms into 25 ms for far
|
|
48
|
+
users; D1's Sessions API (`withSession("first-unconstrained")`) instead serves reads from replicas near the user
|
|
49
|
+
and sends writes to the primary. Smart Placement is the safer default for PocketBase-shaped traffic (a list
|
|
50
|
+
request runs several dependent queries); replication suits read-heavy global apps.
|
|
51
|
+
6. **Lazy wasm.** Import Photon on the first thumbnail request rather than at module load: smaller cold start on
|
|
52
|
+
every other request.
|
|
53
|
+
7. **Queues for the slow and the retryable.** Outbound mail and the automatic backups go through a `queues/`
|
|
54
|
+
consumer in the generated Void project (`src/server/jobs.ts`): the request returns as soon as the message is
|
|
55
|
+
queued, Cloudflare retries failures with backoff, and a message dropped after its last retry is posted to the
|
|
56
|
+
alert webhook. Delivery is at-least-once, so a mail whose SMTP session failed after the server accepted it can
|
|
57
|
+
arrive twice; hooks (`onMailerSend` and friends) run in the request on the final message, the panel's test email
|
|
58
|
+
and `$app.newMailClient()` stay synchronous. Thumbnail pre-generation was tried and dropped: PocketBase renders
|
|
59
|
+
thumbs on demand and a storage listing would show thumbs nobody asked for (the S3 suite compares listings).
|
|
60
|
+
Reliability more than cost ($0.40 per million operations).
|
|
61
|
+
8. **KV for what the edge reads on every request and tolerates 60 s of staleness**: the public `auth-methods`
|
|
62
|
+
answer, the settings snapshot, hostname-to-app when several apps share a Worker. Reads $0.50 per million;
|
|
63
|
+
D1 stays the source of truth.
|
|
64
|
+
9. **Rate limits shared across isolates.** Cloudflare's rate-limiting binding is free and counts per location;
|
|
65
|
+
voidbase's counters are per isolate (documented as approximate). The deploy declares one (`RATE_LIMITER`, a
|
|
66
|
+
ceiling per IP on `/api`, PocketBase's default 300 per 10 s) and the middleware consults it while rate limits are
|
|
67
|
+
enabled. Cloudflare's own docs call the binding permissive and eventually consistent, so it is a shared ceiling,
|
|
68
|
+
not an exact counter; the settings' rules keep their per-isolate windows.
|
|
69
|
+
10. **Realtime as push instead of poll (later).** A per-app Durable Object hub with hibernating WebSockets from
|
|
70
|
+
the edge Workers that hold the SSE streams (Workers bill per request, not wall-clock; the object sleeps between
|
|
71
|
+
pushes: Cloudflare's own example is 100 objects × 100 connections for about $10 per month). It removes the
|
|
72
|
+
poll and its latency. Void exposes Durable Objects only through `.ws.ts` rooms and `void/live` (256 subscribers
|
|
73
|
+
per topic, which a busy collection exceeds), and `void deploy --backend cloudflare` refuses custom Durable
|
|
74
|
+
Object classes, so this waits for Void support or a wrangler-deployed hub. The current poll costs $0.003 per
|
|
75
|
+
isolate-month, so this is a latency improvement, not a cost one.
|
|
76
|
+
|
|
77
|
+
Applying 1 to 4 changes the bill of a typical app from "every request and every minute" to "API requests and
|
|
78
|
+
rows actually written", and 5 and 6 are the two latency wins visible to users.
|
|
79
|
+
|
|
80
|
+
### Status (implemented 2026-09-06)
|
|
81
|
+
|
|
82
|
+
| Item | State | Where |
|
|
83
|
+
| --- | --- | --- |
|
|
84
|
+
| 1 assets off the Worker | done: asset-first with `routing.notFound: "404-page"` and 404.html shells; deep links carry status 404 (docs/differences.md) | `void.json`, `hooks-plugin.ts` `writeNotFoundShells`, `scripts/sync-*.ts` |
|
|
85
|
+
| 2 log threshold | done: request rows are written only at or above `max(settings.logs.minLevel, VOIDBASE_LOG_MIN_LEVEL)`; the Workers default is 4 (warnings: 4xx, 5xx, slow requests), the Bun runtime and the test suites keep 0 | `src/server/logs.ts`, `#platform/env` `defaultLogMinLevel`, `env.ts` |
|
|
86
|
+
| 3 change feed only with listeners | done: the `_changes` insert is a conditional `INSERT ... SELECT ... WHERE EXISTS (SELECT 1 FROM _realtime_clients)` inside the same write batch, so it costs no extra round trip and writes no row when nobody is subscribed | `src/server/records/service.ts` |
|
|
87
|
+
| 4 crons only when needed | done: the hooks plugin reads every `cronAdd` expression at build time and registers them as the Worker's triggers (macros expanded; more than 4 falls back to every minute), plus one hourly tick; PocketBase's maintenance jobs (log and file cleanup, token purge, backups) also run lazily on the next request when overdue, and a tick catches every job due since the last one | `hooks-plugin.ts` `cronTriggers`, `crons/every-minute.ts`, `src/server/crons.ts` |
|
|
88
|
+
| 5 Smart Placement | done for `voidbase deploy` (`placement: { mode: "smart" }` in the generated wrangler config). Caveat from Cloudflare: with `run_worker_first` the whole Worker is placed as one unit, so asset requests from far users travel to the placed region as well; browsers cache those, API latency wins | `src/node/deploy-cf.ts` |
|
|
89
|
+
| 6 lazy wasm | done: Photon is imported on the first thumbnail request | `src/server/records/thumbs.ts` |
|
|
90
|
+
| 7 queues | done (2026-09-06): mail and automatic backups through the `<name>-jobs` queue with retries and an alert on drop; inline without the queue. Verified in dev, in `vp preview` (mail-http) and on a deployed probe | `src/server/jobs.ts`, `queues/jobs.ts`, `src/server/mail/index.ts`, `src/server/backups.ts` |
|
|
91
|
+
| 2b log sink | done, opt-in: Analytics Engine data point per request (`--analytics`); the account must enable Analytics Engine once, which is why it is not the default | `src/server/logs.ts`, `src/node/deploy-cf.ts` |
|
|
92
|
+
| 8 KV | skipped: settings are cached per isolate and Smart Placement makes the remaining D1 read cheap | |
|
|
93
|
+
| 9 rate limits | done: the rate-limit binding as a per-location ceiling per IP while rate limits are enabled (`--rate-limit`); eventually consistent by Cloudflare's design | `src/server/hardening.ts`, `src/node/deploy-cf.ts` |
|
|
94
|
+
| 10 Durable Object hub | done: one SQLite-backed `VoidbaseHub` per instance, exported from the instance's own Worker (the plugin appends it to Void's generated entry; wrangler.jsonc declares the binding and the `new_sqlite_classes` migration, which Void's Cloudflare backend accepts). Each SSE connection holds one hibernatable socket to it, writes publish after their D1 batch commits, subscription changes are relayed through it, and the object sweeps sockets that stopped pinging. Without the binding (Bun, `--no-hub`) the D1 poll stays. Local fan-out to 100 clients: p50 346 ms (poll: 587 ms), one client: about 50 ms. Void itself neither promises custom Durable Objects nor offers a cheaper primitive: `void/live` keeps one active object per open stream and caps a topic at 256 subscribers | `src/server/hub.ts`, `src/server/realtime/`, `hooks-plugin.ts` `hubEntry` |
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
## Beyond 500 apps per account: one Worker, one Durable Object per app
|
|
98
|
+
|
|
99
|
+
Kept here for when the account limit becomes the constraint rather than something to raise.
|
|
100
|
+
|
|
101
|
+
### A data plane of Durable Objects, a thin edge, queues behind
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
browser / SDK ──► edge Worker (router) ──► TenantObject (SQLite-backed Durable Object, one per app)
|
|
105
|
+
│ hostname → tenant (KV) │ SQLite: the PocketBase tables (10 GB per object)
|
|
106
|
+
│ SSE held here (cheap) │ in-memory realtime hub, alarms for crons
|
|
107
|
+
│ hibernating WebSocket ◄────────┘ publishes changes to connected edge Workers
|
|
108
|
+
▼
|
|
109
|
+
R2 (one bucket, tenant/ prefix) Queues (mail, thumbs, webhooks, backups) KV (tenant map, snapshots)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
**Why a Durable Object per app.** SQLite-backed Durable Objects are unlimited in number, created on first use by
|
|
113
|
+
name (no provisioning step, no binding per tenant), hold up to 10 GB each, bill rows read at $0.001 per million,
|
|
114
|
+
rows written at $1 per million and storage at $0.20 per GB-month, and cost nothing while inactive. voidbase's
|
|
115
|
+
database access already goes through the D1 interface with a swappable implementation (`src/node/d1.ts` for
|
|
116
|
+
bun:sqlite); a `ctx.storage.sql` adapter is the same size. Two things get better than D1: transactions are real
|
|
117
|
+
(`transactionSync`), and the object is a single writer, so the settings and collections caches never need
|
|
118
|
+
invalidation.
|
|
119
|
+
|
|
120
|
+
**Why the edge holds the SSE connections.** Workers bill per request and CPU time, never wall-clock, so an
|
|
121
|
+
EventSource that stays open for an hour costs one request plus the CPU spent writing frames. A Durable Object
|
|
122
|
+
holding that same stream would bill duration (128 MB × seconds, $12.50 per million GB-s: about $4 per month per
|
|
123
|
+
permanently connected app). So the router Worker terminates SSE (the PocketBase SDK keeps using SSE unchanged),
|
|
124
|
+
and holds one hibernating WebSocket to the app's object. The object sleeps between writes and wakes only to fan
|
|
125
|
+
out; Cloudflare's own pricing example for this pattern is 100 objects × 100 connections at roughly $10 per month.
|
|
126
|
+
The D1 polling loop disappears, along with its one read per second per app.
|
|
127
|
+
|
|
128
|
+
**Why alarms instead of cron triggers.** A Durable Object alarm is scheduled by the object itself only when the app
|
|
129
|
+
has a job due (`cronAdd` from hooks, a backups cron, a scheduled cleanup). An app without connections, jobs or
|
|
130
|
+
writes has no alarm and no cost. PocketBase's built-in maintenance (OTP/MFA/log cleanup) becomes lazy: run on
|
|
131
|
+
the next request when overdue, which is what an idle app deserves.
|
|
132
|
+
|
|
133
|
+
**Queues** take everything that does not belong in the request: outbound mail (with retries and a dead letter),
|
|
134
|
+
thumbnail generation, hooks' HTTP and email actions, backup archives, audit rows. Batching keeps it at $0.40 per
|
|
135
|
+
million operations; idempotency keys (message id = record id + action) make at-least-once delivery safe.
|
|
136
|
+
|
|
137
|
+
**KV** caches only what tolerates 60 seconds of staleness and is read on every request at the edge: hostname to
|
|
138
|
+
tenant id, the public `auth-methods` answer, the collections snapshot used by the edge for static-asset decisions.
|
|
139
|
+
Reads are $0.50 per million; the object itself is the source of truth.
|
|
140
|
+
|
|
141
|
+
**R2** stays one bucket per platform with keys `tenant/collectionId/recordId/filename` (voidbase already keys
|
|
142
|
+
files by collection and record). Thumbnails are cached under the same prefix; protected files keep the token
|
|
143
|
+
flow; egress is free.
|
|
144
|
+
|
|
145
|
+
**Frontends** of tenant apps are static assets: serve them from R2 by hostname through the router, or, for
|
|
146
|
+
tenants with their own code, through their Workers for Platforms user Worker.
|
|
147
|
+
|
|
148
|
+
### Tenant hooks
|
|
149
|
+
|
|
150
|
+
`pb_hooks` are arbitrary JavaScript. Running many tenants' hooks inside one Worker is not acceptable isolation.
|
|
151
|
+
Three tiers, cheapest first:
|
|
152
|
+
|
|
153
|
+
1. **Data-only apps** (the majority): no custom hooks; the shared core serves them. Rules, auth, files, realtime,
|
|
154
|
+
the `hooks` collection's declarative actions and everything the panel configures still work.
|
|
155
|
+
2. **Apps with hooks**: a Workers for Platforms user Worker per tenant, containing voidbase core plus that
|
|
156
|
+
tenant's compiled `pb_hooks`, bound to the tenant's object through a service binding. Workers for Platforms
|
|
157
|
+
allows unlimited user Workers in a dispatch namespace ($25 per month for the namespace, $0.30 per million
|
|
158
|
+
requests beyond the included 20 million), and an idle user Worker costs nothing. This is exactly the bundle
|
|
159
|
+
`voidbase deploy` builds today, uploaded through the dispatch API instead of `void deploy`.
|
|
160
|
+
3. **Apps with heavy custom code**: the tenant's own account with `voidbase deploy`, which already works.
|
|
161
|
+
|
|
162
|
+
### Cost sketch
|
|
163
|
+
|
|
164
|
+
Per tenant, idle: storage only. A 100 MB app is $0.02 per month of Durable Object storage and less of R2; the
|
|
165
|
+
account's first 5 GB are included. One thousand idle apps: the fixed $5 Workers Paid minimum (plus $25 if the
|
|
166
|
+
dispatch namespace is used) and cents of storage.
|
|
167
|
+
|
|
168
|
+
Per tenant, active (100,000 requests, 1 million rows read, 50,000 rows written, 20 users on realtime all day):
|
|
169
|
+
requests are inside the included 10 million; rows read $0.001; rows written $0.05; the realtime hub wakes for
|
|
170
|
+
each write (100,000 object requests, $0.015) and hibernates otherwise. Under a dime. The platform's bill is
|
|
171
|
+
proportional to activity, which is what lets the customer be charged that way too.
|
|
172
|
+
|
|
173
|
+
### Fit with Void, and the honest gaps
|
|
174
|
+
|
|
175
|
+
Void exposes Durable Objects only through `.ws.ts` rooms (key-value storage) and `void/live` (SSE fanout,
|
|
176
|
+
256 subscribers per topic), infers no dispatch namespace binding, and `void deploy --backend cloudflare` refuses
|
|
177
|
+
apps that declare Durable Object classes. So the data plane described here is deployed with wrangler (the
|
|
178
|
+
generated project already tolerates a custom `wrangler.jsonc`: add `durable_objects` bindings and a
|
|
179
|
+
`new_sqlite_classes` migration), while Void keeps doing what it does well: the control plane app, the dev loop,
|
|
180
|
+
queues, KV, crons, the managed deploy for single-tenant apps. `void/live` is usable as the realtime hub for
|
|
181
|
+
small apps, but PocketBase topics are per collection, so a busy app exceeds 256 subscribers on one topic; the
|
|
182
|
+
tenant object's own hub has no such limit. If Void adds custom SQLite-backed Durable Object classes and a
|
|
183
|
+
dispatch binding, the wrangler step goes away.
|
|
184
|
+
|
|
185
|
+
### The order to build it
|
|
186
|
+
|
|
187
|
+
Each step is independently useful and keeps every existing suite green.
|
|
188
|
+
|
|
189
|
+
1. **`src/platform/do/`**: a D1-interface adapter over `ctx.storage.sql` (batch = `transactionSync`, cursors
|
|
190
|
+
consumed synchronously), an R2 prefix view for `tenant/`, and a `TenantObject` class that runs the existing
|
|
191
|
+
Hono app with those bindings. Verified by the same differential suites through a wrangler-deployed dev
|
|
192
|
+
Worker (miniflare runs SQLite objects locally).
|
|
193
|
+
2. **Realtime hub in the object**: writes publish in-process; the router Worker terminates SSE and subscribes over
|
|
194
|
+
a hibernating WebSocket. Remove the `_changes` polling on this path; keep it for single-tenant D1 deploys.
|
|
195
|
+
3. **Alarms for crons** and lazy built-ins; delete the per-Worker cron trigger on the platform path.
|
|
196
|
+
4. **Queues** for mail, thumbnails and hooks actions (`queues/` files in the Void control plane, the object
|
|
197
|
+
produces). Idempotency keys first.
|
|
198
|
+
5. **Router Worker + KV tenant map**, static assets from R2 by hostname, per-tenant metering with Analytics
|
|
199
|
+
Engine (requests, rows, bytes) so customers can be billed for activity.
|
|
200
|
+
6. **Control plane** (`voidbase cloud`): create/delete apps, domains, tokens; a tenant is a name, nothing is
|
|
201
|
+
provisioned until the first request.
|
|
202
|
+
7. **Workers for Platforms** for tenants with hooks: the existing deploy bundle uploaded to the dispatch
|
|
203
|
+
namespace with a service binding to the tenant object.
|
|
204
|
+
|
|
205
|
+
Things to keep in view: a Durable Object lives in one location (place it with `locationHint` near the app's
|
|
206
|
+
owner; the edge already serves assets and SSE close to users), the 10 GB cap per object (shard or move very large
|
|
207
|
+
tenants to D1), WebSocket hibernation drops in-memory state (subscriptions are re-sent on wake), and Queues are
|
|
208
|
+
at-least-once.
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Releasing
|
|
2
|
+
|
|
3
|
+
The package is `@voidbase-cloud/voidbase`. A release is a git tag `vX.Y.Z` that matches `package.json`; pushing
|
|
4
|
+
the tag runs `.github/workflows/release.yml`, which checks, packs, smoke-installs and publishes.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
# on master, tree clean, CHANGELOG.md has a "## X.Y.Z" section
|
|
8
|
+
npm version minor # or patch / major / 0.2.0: bumps package.json, commits "vX.Y.Z", tags it
|
|
9
|
+
git push --follow-tags # the tag triggers the release workflow
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
What the workflow does, in order:
|
|
13
|
+
|
|
14
|
+
1. `bun install --frozen-lockfile`, the two typechecks, `bun test`, `test/cloud-rest.ts` (self-contained mocks).
|
|
15
|
+
2. Refuses to continue if the tag and `package.json` disagree, or if that version is already on npm.
|
|
16
|
+
3. `npm pack`, then installs the tarball into a temporary project and runs the CLI from it.
|
|
17
|
+
4. `npm publish` to npm with `--access public`, and with `--provenance` when the repository is public (npm's
|
|
18
|
+
provenance needs a public repository; the workflow turns it off while the repository is private).
|
|
19
|
+
5. The same tarball to GitHub Packages (`npm.pkg.github.com`, the `@voidbase-cloud` scope matches the organization).
|
|
20
|
+
6. A GitHub release for the tag with the `CHANGELOG.md` section for that version as notes and the tarball attached.
|
|
21
|
+
|
|
22
|
+
Secrets and permissions: `NPM_TOKEN` (repository secret: an npm granular token with publish rights on the
|
|
23
|
+
`@voidbase-cloud` scope, created at npmjs.com > Access Tokens); GitHub Packages and the release use the
|
|
24
|
+
workflow's own `GITHUB_TOKEN` (`packages: write`, `contents: write`, `id-token: write` for provenance).
|
|
25
|
+
|
|
26
|
+
Try the pipeline without publishing: Actions > release > Run workflow with `dry_run` on (or
|
|
27
|
+
`gh workflow run release.yml -f dry_run=true`). It runs every step with `npm publish --dry-run` and creates no
|
|
28
|
+
release.
|
|
29
|
+
|
|
30
|
+
Publishing by hand, when Actions is not an option:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
bun run check && bun test
|
|
34
|
+
NPM_CONFIG_//registry.npmjs.org/:_authToken=$VOIDBASE_NPM_TOKEN npm publish --access public
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Consumers install with `bun add @voidbase-cloud/voidbase`. From GitHub Packages instead, add to `.npmrc`:
|
|
38
|
+
`@voidbase-cloud:registry=https://npm.pkg.github.com` plus a token with `read:packages`.
|
package/env.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { defineEnv, string } from "void/env";
|
|
2
|
+
|
|
3
|
+
export default defineEnv({
|
|
4
|
+
// First superuser, upserted at bootstrap when both are set (mirrors the starter's entrypoint).
|
|
5
|
+
VOIDBASE_SUPERUSER_EMAIL: string().optional(),
|
|
6
|
+
VOIDBASE_SUPERUSER_PASSWORD: string().optional(),
|
|
7
|
+
// A test user in `users`, created once at start when both are set (voidbase serve; the Worker ignores them)
|
|
8
|
+
VOIDBASE_USER_EMAIL: string().optional(),
|
|
9
|
+
VOIDBASE_USER_PASSWORD: string().optional(),
|
|
10
|
+
// read by pb_hooks via $os.getenv (the starter's audit log uses AUDITLOG=posts,users)
|
|
11
|
+
AUDITLOG: string().optional(),
|
|
12
|
+
// Optional HTTP mail provider instead of SMTP (Resend-compatible JSON: POST {from,to,subject,html,text} with a
|
|
13
|
+
// Bearer key). When set it takes precedence over settings.smtp; the panel's "Send test email" goes through it too.
|
|
14
|
+
VOIDBASE_MAIL_HTTP_URL: string().optional(),
|
|
15
|
+
VOIDBASE_MAIL_HTTP_KEY: string().optional(),
|
|
16
|
+
// Request log threshold on this deployment (PocketBase levels: -4 debug, 0 info, 4 warn, 8 error). Cloudflare defaults
|
|
17
|
+
// to 4 because every request log is a billed D1 row; the Bun runtime defaults to 0. settings.logs.minLevel still applies.
|
|
18
|
+
VOIDBASE_LOG_MIN_LEVEL: string().optional(),
|
|
19
|
+
// Optional error alerts: unhandled request errors (HTTP 500) are POSTed as JSON to this webhook
|
|
20
|
+
VOIDBASE_ALERT_WEBHOOK_URL: string().optional(),
|
|
21
|
+
// PocketBase's --encryptionEnv equivalent: when set (16, 24 or 32 chars) the settings row is stored AES-GCM encrypted
|
|
22
|
+
VOIDBASE_ENCRYPTION_KEY: string().optional(),
|
|
23
|
+
});
|
package/hooks-plugin.ts
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
// Vite plugin: bundles a PocketBase `pb_hooks` directory as the virtual module "virtual:voidbase-hooks".
|
|
2
|
+
// Hook files are written against PocketBase's synchronous JSVM API; here I/O is async, so each file is
|
|
3
|
+
// transformed with the TypeScript compiler API: calls to known I/O methods get `await`, the functions that
|
|
4
|
+
// contain them become `async`, and the change propagates to callers (also across files via exported names).
|
|
5
|
+
import { copyFileSync, existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
6
|
+
import { basename, extname, join, resolve } from "node:path";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
const PLATFORM_MODULES = ["env", "log", "sse", "sockets", "hooks", "migrations", "photon"];
|
|
9
|
+
import ts from "typescript";
|
|
10
|
+
import type { Plugin } from "vite";
|
|
11
|
+
|
|
12
|
+
const VIRTUAL = "virtual:voidbase-hooks";
|
|
13
|
+
const RESOLVED = "\0" + VIRTUAL;
|
|
14
|
+
const VIRTUAL_MIGRATIONS = "virtual:voidbase-migrations";
|
|
15
|
+
const RESOLVED_MIGRATIONS = "\0" + VIRTUAL_MIGRATIONS;
|
|
16
|
+
|
|
17
|
+
// method names whose calls perform I/O in the voidbase runtime
|
|
18
|
+
const ASYNC_PROPS = new Set([
|
|
19
|
+
"next", "submit", "save", "saveNoValidate", "delete", "send", "runInTransaction",
|
|
20
|
+
"findRecordById", "findRecordsByFilter", "findFirstRecordByFilter", "findFirstRecordByData", "findAllRecords", "countRecords",
|
|
21
|
+
"findAuthRecordByEmail", "findAuthRecordByToken", "expandRecord", "expandRecords",
|
|
22
|
+
"fileFromURL", "fileFromBytes", "fileFromPath", "bindBody", "requestInfo",
|
|
23
|
+
"importCollections",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
export const HOOK_GLOBALS = [
|
|
27
|
+
"$app", "$apis", "$http", "$os", "$filesystem", "$security", "$mails", "$template", "$dbx",
|
|
28
|
+
"routerAdd", "routerUse", "cronAdd", "cronRemove", "migrate",
|
|
29
|
+
"Record", "Collection", "RecordUpsertForm", "MailerMessage", "DateTime", "RequestInfo",
|
|
30
|
+
"Field", "TextField", "EditorField", "NumberField", "BoolField", "EmailField", "URLField", "DateField", "AutodateField", "SelectField", "FileField", "RelationField", "JSONField", "GeoPointField", "PasswordField",
|
|
31
|
+
"ApiError", "NotFoundError", "BadRequestError", "ForbiddenError", "UnauthorizedError", "InternalServerError", "ValidationError",
|
|
32
|
+
"__hooks", "require", "module", "exports", "console", "toString", "sleep", "arrayOf", "unmarshal",
|
|
33
|
+
];
|
|
34
|
+
const EVENT_HOOKS = ["Bootstrap", "Serve", "Terminate", "BackupCreate", "BackupRestore",
|
|
35
|
+
"ModelValidate", "ModelCreate", "ModelCreateExecute", "ModelAfterCreateSuccess", "ModelAfterCreateError", "ModelUpdate", "ModelUpdateExecute", "ModelAfterUpdateSuccess", "ModelAfterUpdateError", "ModelDelete", "ModelDeleteExecute", "ModelAfterDeleteSuccess", "ModelAfterDeleteError",
|
|
36
|
+
"RecordEnrich", "RecordValidate", "RecordCreate", "RecordCreateExecute", "RecordAfterCreateSuccess", "RecordAfterCreateError", "RecordUpdate", "RecordUpdateExecute", "RecordAfterUpdateSuccess", "RecordAfterUpdateError", "RecordDelete", "RecordDeleteExecute", "RecordAfterDeleteSuccess", "RecordAfterDeleteError",
|
|
37
|
+
"CollectionValidate", "CollectionCreate", "CollectionCreateExecute", "CollectionAfterCreateSuccess", "CollectionAfterCreateError", "CollectionUpdate", "CollectionUpdateExecute", "CollectionAfterUpdateSuccess", "CollectionAfterUpdateError", "CollectionDelete", "CollectionDeleteExecute", "CollectionAfterDeleteSuccess", "CollectionAfterDeleteError",
|
|
38
|
+
"MailerSend", "MailerRecordAuthAlertSend", "MailerRecordPasswordResetSend", "MailerRecordVerificationSend", "MailerRecordEmailChangeSend", "MailerRecordOTPSend",
|
|
39
|
+
"RealtimeConnectRequest", "RealtimeMessageSend", "RealtimeSubscribeRequest",
|
|
40
|
+
"SettingsListRequest", "SettingsUpdateRequest", "SettingsReload", "FileDownloadRequest", "FileTokenRequest",
|
|
41
|
+
"RecordAuthRequest", "RecordAuthWithPasswordRequest", "RecordAuthRefreshRequest", "RecordRequestPasswordResetRequest", "RecordConfirmPasswordResetRequest", "RecordRequestVerificationRequest", "RecordConfirmVerificationRequest", "RecordRequestEmailChangeRequest", "RecordConfirmEmailChangeRequest", "RecordRequestOTPRequest", "RecordAuthWithOTPRequest",
|
|
42
|
+
"RecordsListRequest", "RecordViewRequest", "RecordCreateRequest", "RecordUpdateRequest", "RecordDeleteRequest",
|
|
43
|
+
"CollectionsListRequest", "CollectionViewRequest", "CollectionCreateRequest", "CollectionUpdateRequest", "CollectionDeleteRequest", "CollectionsImportRequest", "BatchRequest",
|
|
44
|
+
].map((n) => "on" + n);
|
|
45
|
+
export const ALL_GLOBALS = [...HOOK_GLOBALS, ...EVENT_HOOKS];
|
|
46
|
+
|
|
47
|
+
interface FileInfo { name: string; code: string; kind: "hook" | "module" | "file" }
|
|
48
|
+
|
|
49
|
+
function readDir(dir: string): FileInfo[] {
|
|
50
|
+
if (!existsSync(dir)) return [];
|
|
51
|
+
return readdirSync(dir).filter((f) => statSync(join(dir, f)).isFile()).map((f) => {
|
|
52
|
+
const code = readFileSync(join(dir, f), "utf8");
|
|
53
|
+
const kind: FileInfo["kind"] = f.endsWith(".pb.js") ? "hook" : extname(f) === ".js" ? "module" : "file";
|
|
54
|
+
return { name: f, code, kind };
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function calleeName(call: ts.CallExpression): { prop?: string; ident?: string } {
|
|
59
|
+
const e = call.expression;
|
|
60
|
+
if (ts.isPropertyAccessExpression(e)) return { prop: e.name.text };
|
|
61
|
+
if (ts.isIdentifier(e)) return { ident: e.text };
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const isFn = (n: ts.Node): n is ts.FunctionLikeDeclaration => ts.isFunctionDeclaration(n) || ts.isFunctionExpression(n) || ts.isArrowFunction(n) || ts.isMethodDeclaration(n);
|
|
66
|
+
|
|
67
|
+
function declaredName(fn: ts.FunctionLikeDeclaration): string | null {
|
|
68
|
+
if (ts.isFunctionDeclaration(fn) && fn.name) return fn.name.text;
|
|
69
|
+
const p = fn.parent;
|
|
70
|
+
if (p && ts.isVariableDeclaration(p) && ts.isIdentifier(p.name)) return p.name.text;
|
|
71
|
+
if (p && ts.isPropertyAssignment(p) && ts.isIdentifier(p.name)) return p.name.text;
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Marks functions containing async calls; returns the set of named functions that became async.
|
|
76
|
+
function analyze(sf: ts.SourceFile, asyncNames: Set<string>, asyncFns: Set<ts.Node>): boolean {
|
|
77
|
+
let changed = false;
|
|
78
|
+
const visit = (node: ts.Node, enclosing: ts.FunctionLikeDeclaration[]) => {
|
|
79
|
+
if (ts.isCallExpression(node)) {
|
|
80
|
+
const { prop, ident } = calleeName(node);
|
|
81
|
+
const hit = (prop && ASYNC_PROPS.has(prop)) || (ident && asyncNames.has(ident));
|
|
82
|
+
if (hit) {
|
|
83
|
+
const fn = enclosing[enclosing.length - 1];
|
|
84
|
+
if (fn && !asyncFns.has(fn)) {
|
|
85
|
+
asyncFns.add(fn);
|
|
86
|
+
changed = true;
|
|
87
|
+
const name = declaredName(fn);
|
|
88
|
+
if (name && !asyncNames.has(name)) { asyncNames.add(name); changed = true; }
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
const next = isFn(node) ? [...enclosing, node] : enclosing;
|
|
93
|
+
ts.forEachChild(node, (c) => visit(c, next));
|
|
94
|
+
};
|
|
95
|
+
visit(sf, []);
|
|
96
|
+
return changed;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function transform(sf: ts.SourceFile, asyncNames: Set<string>, asyncFns: Set<ts.Node>): string {
|
|
100
|
+
const transformer: ts.TransformerFactory<ts.SourceFile> = (ctx) => {
|
|
101
|
+
const visit = (node: ts.Node): ts.Node => {
|
|
102
|
+
const visited = ts.visitEachChild(node, visit, ctx);
|
|
103
|
+
if (ts.isCallExpression(visited) && !(node.parent && ts.isAwaitExpression(node.parent))) {
|
|
104
|
+
const { prop, ident } = calleeName(node as ts.CallExpression);
|
|
105
|
+
if ((prop && ASYNC_PROPS.has(prop)) || (ident && asyncNames.has(ident))) {
|
|
106
|
+
return ts.factory.createParenthesizedExpression(ts.factory.createAwaitExpression(visited as ts.Expression));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
if (isFn(node) && asyncFns.has(node)) {
|
|
110
|
+
const f = ts.factory;
|
|
111
|
+
const mods = [...(ts.canHaveModifiers(visited) ? ts.getModifiers(visited) ?? [] : [])];
|
|
112
|
+
if (!mods.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)) mods.unshift(f.createToken(ts.SyntaxKind.AsyncKeyword));
|
|
113
|
+
if (ts.isFunctionDeclaration(visited)) return f.updateFunctionDeclaration(visited, mods, visited.asteriskToken, visited.name, visited.typeParameters, visited.parameters, visited.type, visited.body);
|
|
114
|
+
if (ts.isFunctionExpression(visited)) return f.updateFunctionExpression(visited, mods, visited.asteriskToken, visited.name, visited.typeParameters, visited.parameters, visited.type, visited.body);
|
|
115
|
+
if (ts.isArrowFunction(visited)) return f.updateArrowFunction(visited, mods, visited.typeParameters, visited.parameters, visited.type, visited.equalsGreaterThanToken, visited.body);
|
|
116
|
+
if (ts.isMethodDeclaration(visited)) return f.updateMethodDeclaration(visited, mods, visited.asteriskToken, visited.name, visited.questionToken, visited.typeParameters, visited.parameters, visited.type, visited.body);
|
|
117
|
+
}
|
|
118
|
+
return visited;
|
|
119
|
+
};
|
|
120
|
+
return (root) => ts.visitNode(root, visit) as ts.SourceFile;
|
|
121
|
+
};
|
|
122
|
+
const result = ts.transform(sf, [transformer]);
|
|
123
|
+
const printer = ts.createPrinter({ newLine: ts.NewLineKind.LineFeed });
|
|
124
|
+
const out = printer.printFile(result.transformed[0] as ts.SourceFile);
|
|
125
|
+
result.dispose();
|
|
126
|
+
return out;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function compileHooksDir(dir: string): string {
|
|
130
|
+
const files = readDir(dir);
|
|
131
|
+
const sources = new Map<string, ts.SourceFile>();
|
|
132
|
+
for (const f of files) if (f.kind !== "file") sources.set(f.name, ts.createSourceFile(f.name, f.code, ts.ScriptTarget.ES2022, true, ts.ScriptKind.JS));
|
|
133
|
+
const asyncNames = new Set<string>();
|
|
134
|
+
const asyncFns = new Set<ts.Node>();
|
|
135
|
+
for (let i = 0; i < 20; i++) {
|
|
136
|
+
let changed = false;
|
|
137
|
+
for (const sf of sources.values()) changed = analyze(sf, asyncNames, asyncFns) || changed;
|
|
138
|
+
if (!changed) break;
|
|
139
|
+
}
|
|
140
|
+
const destructure = `const { ${ALL_GLOBALS.join(", ")} } = __g;`;
|
|
141
|
+
const hooks: string[] = [];
|
|
142
|
+
const modules: string[] = [];
|
|
143
|
+
const raw: string[] = [];
|
|
144
|
+
for (const f of files) {
|
|
145
|
+
if (f.kind === "file") { raw.push(`${JSON.stringify(f.name)}: ${JSON.stringify(f.code)}`); continue; }
|
|
146
|
+
const body = transform(sources.get(f.name)!, asyncNames, asyncFns);
|
|
147
|
+
if (f.kind === "hook") hooks.push(`{ name: ${JSON.stringify(f.name)}, run: async function (__g) { ${destructure}\n${body}\n} }`);
|
|
148
|
+
else modules.push(`${JSON.stringify(basename(f.name, ".js"))}: async function (__g) { ${destructure}\n${body}\nreturn module.exports; }`);
|
|
149
|
+
}
|
|
150
|
+
return `export const hooksDir = ${JSON.stringify(dir)};\nexport const asyncNames = ${JSON.stringify([...asyncNames])};\nexport const hooks = [${hooks.join(",\n")}];\nexport const modules = { ${modules.join(",\n")} };\nexport const files = { ${raw.join(",\n")} };\n`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// pb_migrations/*.js: each file calls migrate(up, down); the same await insertion applies (app.importCollections,
|
|
154
|
+
// app.save, app.delete ... are I/O here), so `up` becomes async and the runner can await it.
|
|
155
|
+
// The cron expressions registered by the hooks (cronAdd(id, expr, fn)) are known at build time: they become the
|
|
156
|
+
// Worker's cron triggers, so an app without cron hooks needs no every-minute trigger. PocketBase macros are expanded.
|
|
157
|
+
const CRON_MACROS: Record<string, string> = { "@yearly": "0 0 1 1 *", "@annually": "0 0 1 1 *", "@monthly": "0 0 1 * *", "@weekly": "0 0 * * 0", "@daily": "0 0 * * *", "@midnight": "0 0 * * *", "@hourly": "0 * * * *" };
|
|
158
|
+
export function extractCronExpressions(dir: string): string[] {
|
|
159
|
+
const out = new Set<string>();
|
|
160
|
+
for (const f of readDir(dir)) {
|
|
161
|
+
if (f.kind === "file") continue;
|
|
162
|
+
for (const m of f.code.matchAll(/cronAdd\s*\(\s*["'`][^"'`]*["'`]\s*,\s*["'`]([^"'`]+)["'`]/g)) out.add(CRON_MACROS[m[1]!.trim()] ?? m[1]!.trim());
|
|
163
|
+
}
|
|
164
|
+
return [...out];
|
|
165
|
+
}
|
|
166
|
+
// Cloudflare allows 5 triggers per Worker: the hook expressions (plus an hourly tick that runs PocketBase's
|
|
167
|
+
// maintenance and a backups cron configured in settings) or, when there are too many, every minute.
|
|
168
|
+
export function cronTriggers(dir: string): string[] {
|
|
169
|
+
const fromHooks = extractCronExpressions(dir);
|
|
170
|
+
return fromHooks.length > 4 ? ["* * * * *"] : [...new Set(["0 * * * *", ...fromHooks])];
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function compileMigrationsDir(dir: string): string {
|
|
174
|
+
const files = readDir(dir).filter((f) => f.kind !== "file");
|
|
175
|
+
const sources = new Map<string, ts.SourceFile>();
|
|
176
|
+
for (const f of files) sources.set(f.name, ts.createSourceFile(f.name, f.code, ts.ScriptTarget.ES2022, true, ts.ScriptKind.JS));
|
|
177
|
+
const asyncNames = new Set<string>();
|
|
178
|
+
const asyncFns = new Set<ts.Node>();
|
|
179
|
+
for (let i = 0; i < 20; i++) {
|
|
180
|
+
let changed = false;
|
|
181
|
+
for (const sf of sources.values()) changed = analyze(sf, asyncNames, asyncFns) || changed;
|
|
182
|
+
if (!changed) break;
|
|
183
|
+
}
|
|
184
|
+
const destructure = `const { ${ALL_GLOBALS.join(", ")} } = __g;`;
|
|
185
|
+
const out = files.sort((a, b) => a.name.localeCompare(b.name)).map((f) =>
|
|
186
|
+
`{ name: ${JSON.stringify(f.name)}, run: async function (__g) { ${destructure}\n${transform(sources.get(f.name)!, asyncNames, asyncFns)}\n} }`);
|
|
187
|
+
return `export const migrationsDir = ${JSON.stringify(dir)};\nexport const migrations = [${out.join(",\n")}];\n`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** Copies index.html to 404.html (and _/index.html to _/404.html) so Cloudflare's 404-page handling serves the SPA shells. */
|
|
191
|
+
export function writeNotFoundShells(dir: string): string[] {
|
|
192
|
+
const written: string[] = [];
|
|
193
|
+
for (const sub of ["", "_/"]) {
|
|
194
|
+
const index = join(dir, sub, "index.html"); const notFound = join(dir, sub, "404.html");
|
|
195
|
+
if (existsSync(index) && !existsSync(notFound)) { copyFileSync(index, notFound); written.push(`${sub}404.html`); }
|
|
196
|
+
}
|
|
197
|
+
return written;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function pbHooksPlugin(options: { dir?: string; migrationsDir?: string; hubEntry?: string } = {}): Plugin {
|
|
201
|
+
const dir = resolve(options.dir ?? process.env.VOIDBASE_HOOKS_DIR ?? "pb_hooks");
|
|
202
|
+
const migrationsDir = resolve(options.migrationsDir ?? process.env.VOIDBASE_MIGRATIONS_DIR ?? "pb_migrations");
|
|
203
|
+
// hubEntry: the module exporting VoidbaseHub (src/server/hub.ts). Void generates the Worker entry (.void/entry.ts)
|
|
204
|
+
// and exports only its own classes, so the instance's Durable Object class is appended to that entry at bundle time;
|
|
205
|
+
// wrangler.jsonc declares the binding (HUB) and the new_sqlite_classes migration.
|
|
206
|
+
const hubEntry = options.hubEntry ? resolve(options.hubEntry) : "";
|
|
207
|
+
const here = resolve(fileURLToPath(new URL(".", import.meta.url)));
|
|
208
|
+
let clientOut = "";
|
|
209
|
+
return {
|
|
210
|
+
name: "voidbase-pb-hooks",
|
|
211
|
+
// the Workers build takes the workers flavour of every #platform module (package.json "imports" covers Bun/Node)
|
|
212
|
+
config() { return { resolve: { alias: PLATFORM_MODULES.map((n) => ({ find: `#platform/${n}`, replacement: resolve(here, "src/platform/workers", `${n}.ts`) })) } }; },
|
|
213
|
+
configResolved(config) { clientOut = resolve(config.root, config.environments?.client?.build?.outDir ?? config.build.outDir); },
|
|
214
|
+
// Asset-first on Cloudflare: the asset layer answers every request outside /api, so the Worker is never invoked
|
|
215
|
+
// for static files. PocketBase's index fallback for deep links is expressed as Cloudflare's `not_found_handling:
|
|
216
|
+
// "404-page"` (void.json routing.notFound): the nearest 404.html is served with status 404, so the SPA shell and the
|
|
217
|
+
// panel's index are copied to 404.html at build time. Unknown /api paths keep their JSON 404 (the binding returns a
|
|
218
|
+
// 404 for them, which Void's entry only swaps for the HTML page on browser navigations).
|
|
219
|
+
closeBundle() { writeNotFoundShells(clientOut); },
|
|
220
|
+
transform(code, id) {
|
|
221
|
+
if (hubEntry && id.replace(/\\/g, "/").endsWith("/.void/entry.ts")) return { code: `${code}\nexport { VoidbaseHub } from ${JSON.stringify(hubEntry)};\n`, map: null };
|
|
222
|
+
return null;
|
|
223
|
+
},
|
|
224
|
+
resolveId(id) { return id === VIRTUAL ? RESOLVED : id === VIRTUAL_MIGRATIONS ? RESOLVED_MIGRATIONS : null; },
|
|
225
|
+
load(id) {
|
|
226
|
+
if (id === RESOLVED) {
|
|
227
|
+
if (existsSync(dir)) for (const f of readdirSync(dir)) this.addWatchFile(join(dir, f));
|
|
228
|
+
return compileHooksDir(dir);
|
|
229
|
+
}
|
|
230
|
+
if (id === RESOLVED_MIGRATIONS) {
|
|
231
|
+
if (existsSync(migrationsDir)) for (const f of readdirSync(migrationsDir)) this.addWatchFile(join(migrationsDir, f));
|
|
232
|
+
return compileMigrationsDir(migrationsDir);
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|