@voidbase-cloud/voidbase 0.2.2 → 0.4.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/CHANGELOG.md +54 -0
- package/README.md +16 -7
- package/bin/voidbase.ts +67 -6
- package/docs/adapter.md +280 -0
- package/docs/ci.md +195 -0
- package/docs/deploy.md +83 -5
- package/docs/releasing.md +39 -31
- package/hooks-plugin.ts +15 -4
- package/package.json +10 -4
- package/routes/api/[...path].ts +0 -5
- package/scripts/cf-builds.ts +228 -0
- package/scripts/ci-browser.sh +60 -0
- package/scripts/ci-cache.sh +40 -0
- package/scripts/ci-lib.sh +40 -0
- package/scripts/ci-oracles.sh +19 -0
- package/scripts/ci-plan.ts +270 -0
- package/scripts/ci-status.ts +126 -0
- package/scripts/ci-suites.sh +11 -1
- package/scripts/ci.sh +188 -0
- package/scripts/gh-release.ts +48 -0
- package/scripts/release.sh +104 -0
- package/scripts/seed-reference.sh +7 -2
- package/scripts/sync-app.ts +1 -0
- package/src/adapter/bundle.ts +130 -0
- package/src/adapter/codegen.ts +269 -0
- package/src/adapter/index.ts +6 -0
- package/src/adapter/plugin.ts +132 -0
- package/src/adapter/runtime.ts +325 -0
- package/src/adapter/scan.ts +277 -0
- package/src/cloud/rest.ts +10 -2
- package/src/env/define.ts +195 -0
- package/src/node/assets.ts +7 -1
- package/src/node/cloud-init.ts +14 -0
- package/src/node/deploy-cf.ts +124 -16
- package/src/node/secrets.ts +237 -0
- package/src/node/serve.ts +16 -2
- package/src/server/api.ts +7 -2
- package/src/server/app.ts +6 -1
- package/src/server/hooks/index.ts +27 -1
- package/src/server/hooks/migrations.ts +4 -1
- package/src/server/hooks/runtime.ts +10 -2
- package/src/server/jobs.ts +3 -1
- package/src/server/webauthn.ts +23 -6
- package/tsconfig.json +5 -0
- package/tsconfig.node.json +3 -1
package/scripts/ci.sh
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# The whole CI flow in one script (docs/ci.md), the same on a dev machine and on Cloudflare Workers Builds (GitHub
|
|
3
|
+
# Actions only starts the builds): commit messages, typecheck and unit tests, the differential and panel suites against a reference
|
|
4
|
+
# PocketBase, the same suites on the Bun runtime, the deploy dry run, the production-build boots, the prebuilt
|
|
5
|
+
# executable and the unmodified starter. Every step is recorded for the status page (ci/public, scripts/ci-status.ts);
|
|
6
|
+
# the script stops at the first failed step and exits non-zero. It stops the servers it started when it ends.
|
|
7
|
+
# scripts/ci.sh
|
|
8
|
+
# Environment: STARTER_DIR (scripts/ci-oracles.sh resolves it), CHROME_PATH (scripts/ci-browser.sh finds or provisions
|
|
9
|
+
# a Chrome), CI_BROWSER=0 skips the browser suites, CI_PORT (5180) and CI_PB_PORT (8090) for voidbase and the reference,
|
|
10
|
+
# CI_CACHE_DIR for the downloads kept between runs, CI_STATUS_URL for the record of the last green run
|
|
11
|
+
# (scripts/ci-plan.ts skips what that run already verified on the same inputs; CI_PLAN=full runs everything).
|
|
12
|
+
set -uo pipefail
|
|
13
|
+
cd "$(dirname "$0")/.."; ROOT="$PWD"
|
|
14
|
+
. scripts/ci-lib.sh
|
|
15
|
+
export VOIDBASE_SUPERUSER_EMAIL="${VOIDBASE_SUPERUSER_EMAIL:-admin@example.com}"
|
|
16
|
+
export VOIDBASE_SUPERUSER_PASSWORD="${VOIDBASE_SUPERUSER_PASSWORD:-changeme123}"
|
|
17
|
+
export AUDITLOG="${AUDITLOG:-posts,users}" VOIDBASE_LOG_MIN_LEVEL=0
|
|
18
|
+
BACKEND=$(ci_backend); export CI_BACKEND_NAME="$BACKEND"
|
|
19
|
+
PORT="${CI_PORT:-5180}"; PB_PORT="${CI_PB_PORT:-8090}"; VB="http://127.0.0.1:$PORT"; PB="http://127.0.0.1:$PB_PORT"
|
|
20
|
+
LOGS="$ROOT/.void/ci-logs"; rm -rf "$LOGS" "$CI_STEPS_TSV" .void/ci-plan.txt .void/ci-plan.json; mkdir -p "$LOGS"
|
|
21
|
+
CI_CACHE_DIR="$(ci_cache_dir)"; export CI_CACHE_DIR; mkdir -p "$CI_CACHE_DIR"
|
|
22
|
+
started_pb=0; booted=0
|
|
23
|
+
echo "voidbase ci on $BACKEND: $(git rev-parse --short HEAD 2>/dev/null || echo '?') $(git log -1 --format=%s 2>/dev/null | cut -c1-80), bun $(bun --version)"
|
|
24
|
+
echo "cache: $CI_CACHE_DIR ($(du -sh "$CI_CACHE_DIR" 2>/dev/null | cut -f1 || echo empty))"
|
|
25
|
+
|
|
26
|
+
cleanup() {
|
|
27
|
+
local rc=$?
|
|
28
|
+
./scripts/starter.sh stop >/dev/null 2>&1 || true
|
|
29
|
+
./scripts/dev.sh stop >/dev/null 2>&1 || true
|
|
30
|
+
for d in serve smtp-sink mock-oidc s3-mock cf-mock; do stop_daemon "$d"; done
|
|
31
|
+
stop_reference
|
|
32
|
+
if [ -f .void/ci-env.backup ]; then mv .void/ci-env.backup .env; fi
|
|
33
|
+
if [ "$rc" != 0 ]; then
|
|
34
|
+
echo; echo "--- dev.log"; tail -n 60 .void/dev.log 2>/dev/null; echo "--- reference"; tail -n 30 .void/reference/pb.log 2>/dev/null
|
|
35
|
+
for f in "$LOGS"/*.log "$LOGS"/bun/*.log; do [ -f "$f" ] && grep -qE "^FAIL" "$f" 2>/dev/null && { echo "--- $f"; grep -E "^FAIL|Error|error:" "$f" | head -8; }; done
|
|
36
|
+
fi
|
|
37
|
+
render_status --kind ci || true
|
|
38
|
+
if [ "$rc" = 0 ]; then echo "CI PASSED"; else echo "CI FAILED (exit $rc)"; fi
|
|
39
|
+
}
|
|
40
|
+
trap cleanup EXIT
|
|
41
|
+
|
|
42
|
+
install() { bun install --frozen-lockfile; }
|
|
43
|
+
commitlint_check() { # the commits this run introduces; the last one when there is nothing to compare with
|
|
44
|
+
local from="" to="HEAD"
|
|
45
|
+
if [ "$BACKEND" = github ] && [ "${GITHUB_EVENT_NAME:-}" = pull_request ]; then
|
|
46
|
+
from=$(bun -e 'const e = await Bun.file(process.env.GITHUB_EVENT_PATH!).json(); console.log(e.pull_request.base.sha)'); to=$(bun -e 'const e = await Bun.file(process.env.GITHUB_EVENT_PATH!).json(); console.log(e.pull_request.head.sha)')
|
|
47
|
+
elif [ "$BACKEND" = github ]; then
|
|
48
|
+
from=$(bun -e 'const e = await Bun.file(process.env.GITHUB_EVENT_PATH!).json(); console.log(e.before ?? "")'); [ "$from" = "0000000000000000000000000000000000000000" ] && from=""
|
|
49
|
+
elif [ "$BACKEND" = cloudflare ] && [ "${WORKERS_CI_BRANCH:-master}" != master ]; then
|
|
50
|
+
git fetch --quiet --depth=200 origin master 2>/dev/null && from=$(git merge-base origin/master HEAD 2>/dev/null || true)
|
|
51
|
+
elif [ "$BACKEND" = local ]; then from=$(git merge-base origin/master HEAD 2>/dev/null || true); fi
|
|
52
|
+
if [ -n "$from" ] && [ "$from" != "$(git rev-parse "$to")" ] && git cat-file -e "$from" 2>/dev/null; then node_modules/.bin/commitlint --from "$from" --to "$to" --verbose; else node_modules/.bin/commitlint --last --verbose; fi
|
|
53
|
+
}
|
|
54
|
+
oracles() { # the starter and the panel next to a production-shaped public/ (the SPA shell the boot test checks)
|
|
55
|
+
. scripts/ci-oracles.sh
|
|
56
|
+
XDG_CACHE_HOME="$CI_CACHE_DIR/xdg" bun run panel:sync # scripts/sync-panel.ts keeps the panel tarball under it
|
|
57
|
+
# the starter's frontend build is kept with the clone and reused while the starter's commit is the same
|
|
58
|
+
local head stamp; head=$(git -C "$STARTER_DIR" rev-parse HEAD 2>/dev/null || echo none); stamp="$STARTER_DIR/sk/build/.voidbase-ci-stamp"
|
|
59
|
+
if [ -d "$STARTER_DIR/sk/build" ] && [ -f "$stamp" ] && [ "$(cat "$stamp")" = "$head" ]; then echo "starter frontend build reused ($head)"
|
|
60
|
+
else (cd "$STARTER_DIR/sk" && bun install --frozen-lockfile && bunx svelte-kit sync && bun run build) && echo "$head" > "$stamp"; fi
|
|
61
|
+
VOIDBASE_APP_DIR="$STARTER_DIR/sk/build" bun run app:sync
|
|
62
|
+
./node_modules/.bin/void prepare
|
|
63
|
+
}
|
|
64
|
+
plan() { bun scripts/ci-plan.ts; }
|
|
65
|
+
cache_restore() { ./scripts/ci-cache.sh restore; }
|
|
66
|
+
cache_save() { ./scripts/ci-cache.sh save; }
|
|
67
|
+
typecheck() { bunx tsc --noEmit -p tsconfig.json && bunx tsc --noEmit -p tsconfig.node.json && bunx tsc --noEmit -p tsconfig.scripts.json; }
|
|
68
|
+
unit() { bun test; }
|
|
69
|
+
browser() { local exports; exports=$(./scripts/ci-browser.sh) || return 1; eval "$exports"; echo "$exports"; }
|
|
70
|
+
boot() {
|
|
71
|
+
# the Worker reads its vars from .env (Void bakes them), not from the shell: the run writes its own values so both
|
|
72
|
+
# sides serve the same starter; a dev machine's file is put back when the run ends (cleanup)
|
|
73
|
+
if [ -f .env ] && [ ! -f .void/ci-env.backup ]; then cp .env .void/ci-env.backup; fi
|
|
74
|
+
printf 'VOIDBASE_SUPERUSER_EMAIL=%s\nVOIDBASE_SUPERUSER_PASSWORD=%s\nVOIDBASE_HOOKS_DIR=%s\nVOIDBASE_MIGRATIONS_DIR=%s\nAUDITLOG=%s\nVOIDBASE_LOG_MIN_LEVEL=0\n' "$VOIDBASE_SUPERUSER_EMAIL" "$VOIDBASE_SUPERUSER_PASSWORD" "$STARTER_DIR/pb/pb_hooks" "$STARTER_DIR/pb/pb_migrations" "$AUDITLOG" > .env
|
|
75
|
+
./node_modules/.bin/void db migrate
|
|
76
|
+
./scripts/dev.sh start "$PORT" && booted=1
|
|
77
|
+
./scripts/seed-app-user.sh "$VB"
|
|
78
|
+
warm_up
|
|
79
|
+
}
|
|
80
|
+
warm_up() { # first requests to the paths whose dependencies Vite+ optimizes on first use (a reload that would lose a
|
|
81
|
+
# suite's in-flight work, such as a queued mail), then wait until the optimizer has been quiet for a few seconds
|
|
82
|
+
local j='content-type: application/json'
|
|
83
|
+
curl -s -o /dev/null -X POST "$VB/api/collections/users/auth-with-password" -H "$j" -d '{"identity":"user@example.com","password":"changeme123"}'
|
|
84
|
+
curl -s -o /dev/null -X POST "$VB/api/collections/users/request-password-reset" -H "$j" -d '{"email":"user@example.com"}'
|
|
85
|
+
curl -s -o /dev/null "$VB/api/collections/users/auth-methods"
|
|
86
|
+
curl -s -o /dev/null -m 2 "$VB/api/realtime" || true
|
|
87
|
+
curl -s -o /dev/null "$VB/api/collections/posts/records?perPage=1"
|
|
88
|
+
local before after; for _ in $(seq 1 20); do before=$(grep -cE "optimized|program reload" .void/dev.log 2>/dev/null); sleep 3; after=$(grep -cE "optimized|program reload" .void/dev.log 2>/dev/null); [ "$before" = "$after" ] && break; done
|
|
89
|
+
wait_http "$VB/api/health" 30; echo "warm: optimizer quiet after $after optimization(s)"
|
|
90
|
+
}
|
|
91
|
+
helper() { local name="$1" port="$2"; shift 2; if port_busy "$port"; then echo "reusing $name on $port"; else daemon "$name" ".void/$name.log" "$@"; echo "started $name on $port"; fi; }
|
|
92
|
+
start_reference() { # a freshly seeded reference: state left by one run or one pass never reaches the next
|
|
93
|
+
rm -rf .void/reference/pb_data
|
|
94
|
+
./scripts/seed-reference.sh .void/reference "$PB_PORT" 0.39.11 "$STARTER_DIR" && started_pb=1
|
|
95
|
+
}
|
|
96
|
+
stop_reference() { if [ "$started_pb" = 1 ] && [ -f .void/reference/pb.pid ]; then kill "$(cat .void/reference/pb.pid)" 2>/dev/null; for _ in $(seq 1 30); do port_busy "$PB_PORT" || break; sleep 0.5; done; started_pb=0; fi; }
|
|
97
|
+
reference() {
|
|
98
|
+
if port_busy "$PB_PORT"; then echo "reusing the PocketBase listening on $PB"; else start_reference; fi
|
|
99
|
+
helper smtp-sink 2525 bun test/smtp-sink.ts
|
|
100
|
+
helper mock-oidc 5190 bun test/mock-oidc.ts
|
|
101
|
+
helper s3-mock 5195 bun test/s3-mock.ts
|
|
102
|
+
helper cf-mock 5197 bun test/cf-mock.ts
|
|
103
|
+
wait_http http://127.0.0.1:2526/messages 30; wait_http http://127.0.0.1:5190/ 30; wait_http http://127.0.0.1:5195/ 30; wait_http http://127.0.0.1:5197/__state 30
|
|
104
|
+
warm_mail
|
|
105
|
+
}
|
|
106
|
+
warm_mail() { # the first mail through the SMTP transport goes out here, to the sink, before any suite waits for one
|
|
107
|
+
local j='content-type: application/json' tok n=0
|
|
108
|
+
tok=$(curl -s -X POST "$VB/api/collections/_superusers/auth-with-password" -H "$j" -d "{\"identity\":\"$VOIDBASE_SUPERUSER_EMAIL\",\"password\":\"$VOIDBASE_SUPERUSER_PASSWORD\"}" | sed -n 's/.*"token":"\([^"]*\)".*/\1/p')
|
|
109
|
+
[ -n "$tok" ] || { echo "warm: superuser login failed, the mail transport stays cold"; return 0; }
|
|
110
|
+
curl -s -o /dev/null -X DELETE http://127.0.0.1:2526/messages
|
|
111
|
+
curl -s -o /dev/null -X PATCH "$VB/api/settings" -H "$j" -H "authorization: $tok" -d '{"smtp":{"enabled":true,"host":"127.0.0.1","port":2525,"username":"","password":"","authMethod":"","tls":false,"localName":""}}'
|
|
112
|
+
# the settings test email: a password reset for the same user would be rate-limited after the boot warm-up
|
|
113
|
+
curl -s -o /dev/null -X POST "$VB/api/settings/test/email" -H "$j" -H "authorization: $tok" -d '{"email":"warm@example.com","template":"verification"}'
|
|
114
|
+
for _ in $(seq 1 20); do n=$(curl -s http://127.0.0.1:2526/messages | grep -o '"subject"' | wc -l); [ "$n" -ge 1 ] && break; sleep 1; done
|
|
115
|
+
# back to the defaults a fresh database starts with (PocketBase's), which the settings comparisons expect
|
|
116
|
+
curl -s -o /dev/null -X PATCH "$VB/api/settings" -H "$j" -H "authorization: $tok" -d '{"smtp":{"enabled":false,"host":"smtp.example.com","port":587,"username":"","password":"","authMethod":"","tls":false,"localName":""}}'
|
|
117
|
+
curl -s -o /dev/null -X DELETE http://127.0.0.1:2526/messages
|
|
118
|
+
echo "warm: mail transport ${n:-0} message(s) delivered to the sink"
|
|
119
|
+
}
|
|
120
|
+
# shellcheck disable=SC2086
|
|
121
|
+
suites() { ./scripts/ci-suites.sh "$PB" "$VB" $(plan_list suites); }
|
|
122
|
+
suites_bun() { # the selected suites against `voidbase serve` (Bun runtime, SQLite + local files)
|
|
123
|
+
[ "$booted" = 1 ] && ./scripts/dev.sh stop
|
|
124
|
+
# the reference PocketBase keeps some state the suites cannot undo (a stored S3 secret, for one), so the Bun pass
|
|
125
|
+
# gets a fresh one: both sides of every comparison then start from the same state
|
|
126
|
+
if [ "$started_pb" = 1 ]; then stop_reference; start_reference; fi
|
|
127
|
+
rm -rf .void/ci-serve; mkdir -p .void/ci-serve
|
|
128
|
+
daemon serve .void/serve.log bun bin/voidbase.ts serve --http 127.0.0.1:8093 --dir .void/ci-serve/pb_data --hooksDir "$STARTER_DIR/pb/pb_hooks" --migrationsDir "$STARTER_DIR/pb/pb_migrations"
|
|
129
|
+
wait_http http://127.0.0.1:8093/api/health 60
|
|
130
|
+
./scripts/seed-app-user.sh http://127.0.0.1:8093
|
|
131
|
+
# shellcheck disable=SC2086
|
|
132
|
+
CI_BROWSER=0 CI_LOGS="$LOGS/bun" ./scripts/ci-suites.sh "$PB" http://127.0.0.1:8093 $(plan_list bun); local rc=$?
|
|
133
|
+
stop_daemon serve
|
|
134
|
+
[ "$booted" = 1 ] && ./scripts/dev.sh start "$PORT"
|
|
135
|
+
return "$rc"
|
|
136
|
+
}
|
|
137
|
+
deploy_cf() { bun test/deploy-cf.ts; }
|
|
138
|
+
adapter() { bun test/adapter.ts; } # a Void app converted into a voidbase app, then run
|
|
139
|
+
fresh_db() { bun test/fresh-db.ts 5181; }
|
|
140
|
+
mail_http() { bun test/mail-http.ts 5184; }
|
|
141
|
+
exe_smoke() { STARTER_VB_DIR="$STARTER_DIR/pb" bun test/exe-smoke.ts; }
|
|
142
|
+
starter() { # the unmodified starter frontend against voidbase
|
|
143
|
+
STARTER_SK_DIR="$STARTER_DIR/sk" ./scripts/starter.sh start 5174 "$VB"
|
|
144
|
+
bun test/starter-smoke.ts http://127.0.0.1:5174 "$LOGS/starter.png"
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
release_work() { # release-please, npm and the executables in this build (docs/releasing.md): on master, when the
|
|
148
|
+
# commits ask for it or a release still needs publishing; hot mode publishes to npm and leaves the executables
|
|
149
|
+
local branch; branch="${WORKERS_CI_BRANCH:-$(git rev-parse --abbrev-ref HEAD 2>/dev/null)}"
|
|
150
|
+
if [ "$branch" != master ]; then skip_step release "not master"; return 0; fi
|
|
151
|
+
if [ -z "${GH_TOKEN:-}" ]; then skip_step release "no GH_TOKEN"; return 0; fi
|
|
152
|
+
local why=""; plan_flag release-merge && why="the release PR was merged"; plan_flag release-pr && why="${why:-releasable commits, refreshing the release PR}"; plan_flag release-dry-run && why="${why:-dry run requested by a commit}"
|
|
153
|
+
if [ -z "$why" ]; then # a release that still needs publishing or its executables: cut by hand, or left by hot mode
|
|
154
|
+
local v rel; v=$(node -p "require('./package.json').version"); rel=$(bun scripts/gh-release.ts view "v$v" 2>/dev/null) || rel=""
|
|
155
|
+
if [ -n "$rel" ]; then
|
|
156
|
+
if ! npm view "@voidbase-cloud/voidbase@$v" version >/dev/null 2>&1; then why="release v$v is not on npm yet"
|
|
157
|
+
elif ! printf '%s' "$rel" | grep -q checksums.txt && [ "${CI_HOT:-0}" != 1 ]; then why="release v$v has no executables yet"; fi
|
|
158
|
+
fi
|
|
159
|
+
fi
|
|
160
|
+
if [ -z "$why" ]; then skip_step release "nothing to release"; return 0; fi
|
|
161
|
+
echo; echo "=== release: $why"
|
|
162
|
+
local args=(); plan_flag release-pr || args+=(--no-pr); [ "${CI_HOT:-0}" = 1 ] && args+=(--hot); plan_flag release-dry-run && args+=(--dry-run)
|
|
163
|
+
export CI_STEPS_DIR CI_STEPS_TSV; CI_NESTED=1 bash scripts/release.sh "${args[@]}"
|
|
164
|
+
}
|
|
165
|
+
run() { step "$@" || exit 1; }
|
|
166
|
+
# maybe <step> <command...>: the step when the plan selects it, else a recorded skip
|
|
167
|
+
maybe() { local name="$1"; shift; if plan_run "step:$name"; then run "$name" "$@"; else skip_step "$name" "$(plan_reason "step:$name")"; fi; }
|
|
168
|
+
run install install
|
|
169
|
+
run cache-restore cache_restore
|
|
170
|
+
run commitlint commitlint_check
|
|
171
|
+
run plan plan
|
|
172
|
+
maybe oracles oracles
|
|
173
|
+
maybe typecheck typecheck
|
|
174
|
+
maybe unit unit
|
|
175
|
+
maybe browser browser
|
|
176
|
+
maybe boot boot
|
|
177
|
+
maybe reference reference
|
|
178
|
+
maybe suites suites
|
|
179
|
+
maybe suites-bun suites_bun
|
|
180
|
+
maybe deploy-cf deploy_cf
|
|
181
|
+
maybe adapter adapter
|
|
182
|
+
maybe fresh-db fresh_db
|
|
183
|
+
maybe mail-http mail_http
|
|
184
|
+
maybe exe-smoke exe_smoke
|
|
185
|
+
maybe starter starter
|
|
186
|
+
release_work || exit 1
|
|
187
|
+
run cache-save cache_save
|
|
188
|
+
echo; echo "every selected step passed"
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// GitHub releases through the REST API for scripts/release.sh, on machines without `gh` (the Workers Builds image):
|
|
2
|
+
// bun scripts/gh-release.ts view <tag> prints {id, tag_name, html_url, assets} or exits 2 when there is no such release
|
|
3
|
+
// bun scripts/gh-release.ts upload <tag> <file...> attaches files, replacing same-named assets
|
|
4
|
+
// bun scripts/gh-release.ts body <tag> prints the release notes
|
|
5
|
+
// bun scripts/gh-release.ts notes <tag> <file> replaces the release notes with the file's content
|
|
6
|
+
// GH_TOKEN (or GITHUB_TOKEN; `view` and `body` work without one on a public repository); GITHUB_REPOSITORY (default
|
|
7
|
+
// voidbase-cloud/voidbase); GITHUB_API_URL for a mock.
|
|
8
|
+
import { basename } from "node:path";
|
|
9
|
+
|
|
10
|
+
const token = process.env.GH_TOKEN ?? process.env.GITHUB_TOKEN;
|
|
11
|
+
const [cmd, tag, ...files] = process.argv.slice(2);
|
|
12
|
+
if (!token && cmd !== "view" && cmd !== "body") { console.error("GH_TOKEN is not set"); process.exit(1); }
|
|
13
|
+
const repo = process.env.GITHUB_REPOSITORY ?? "voidbase-cloud/voidbase";
|
|
14
|
+
const api = (process.env.GITHUB_API_URL ?? "https://api.github.com").replace(/\/$/, "");
|
|
15
|
+
const headers: Record<string, string> = { ...(token ? { authorization: `Bearer ${token}` } : {}), accept: "application/vnd.github+json", "x-github-api-version": "2022-11-28", "user-agent": "voidbase-release" };
|
|
16
|
+
interface Asset { id: number; name: string }
|
|
17
|
+
interface Release { id: number; tag_name: string; html_url: string; body: string | null; upload_url: string; assets: Asset[] }
|
|
18
|
+
async function call<T>(method: string, url: string, body?: unknown, raw?: { bytes: Uint8Array; type: string }): Promise<{ status: number; data: T }> {
|
|
19
|
+
const h = raw ? { ...headers, "content-type": raw.type } : body !== undefined ? { ...headers, "content-type": "application/json" } : headers;
|
|
20
|
+
const res = await fetch(url.startsWith("http") ? url : `${api}${url}`, { method, headers: h, body: raw ? new Blob([raw.bytes as BlobPart]) : body !== undefined ? JSON.stringify(body) : undefined });
|
|
21
|
+
const text = await res.text(); let data: unknown = text; try { data = text ? JSON.parse(text) : null; } catch { /* not JSON */ }
|
|
22
|
+
return { status: res.status, data: data as T };
|
|
23
|
+
}
|
|
24
|
+
const fail = (what: string, r: { status: number; data: unknown }) => { console.error(`${what}: HTTP ${r.status} ${typeof r.data === "string" ? r.data.slice(0, 300) : JSON.stringify(r.data).slice(0, 300)}`); process.exit(1); };
|
|
25
|
+
if (!cmd || !tag || !["view", "upload", "body", "notes"].includes(cmd)) { console.error("usage: bun scripts/gh-release.ts view|body <tag> | upload <tag> <file...> | notes <tag> <file>"); process.exit(2); }
|
|
26
|
+
const got = await call<Release>("GET", `/repos/${repo}/releases/tags/${encodeURIComponent(tag)}`);
|
|
27
|
+
if (got.status === 404) { console.error(`no release ${tag} in ${repo}`); process.exit(2); }
|
|
28
|
+
if (got.status !== 200) fail(`reading release ${tag}`, got);
|
|
29
|
+
const rel = got.data;
|
|
30
|
+
if (cmd === "view") console.log(JSON.stringify({ id: rel.id, tag_name: rel.tag_name, html_url: rel.html_url, assets: rel.assets.map((a) => a.name) }));
|
|
31
|
+
else if (cmd === "body") process.stdout.write(rel.body ?? "");
|
|
32
|
+
else if (cmd === "notes") {
|
|
33
|
+
if (!files[0]) { console.error("notes: file missing"); process.exit(2); }
|
|
34
|
+
const r = await call("PATCH", `/repos/${repo}/releases/${rel.id}`, { body: await Bun.file(files[0]).text() });
|
|
35
|
+
if (r.status !== 200) fail(`updating the notes of ${tag}`, r);
|
|
36
|
+
console.log(`notes of ${tag} updated`);
|
|
37
|
+
} else {
|
|
38
|
+
if (!files.length) { console.error("upload: no files"); process.exit(2); }
|
|
39
|
+
for (const f of files) {
|
|
40
|
+
const name = basename(f), bytes = new Uint8Array(await Bun.file(f).arrayBuffer());
|
|
41
|
+
const existing = rel.assets.find((a) => a.name === name);
|
|
42
|
+
if (existing) { const d = await call("DELETE", `/repos/${repo}/releases/assets/${existing.id}`); if (d.status !== 204) fail(`replacing ${name}`, d); }
|
|
43
|
+
const type = name.endsWith(".zip") ? "application/zip" : name.endsWith(".tgz") ? "application/gzip" : "text/plain";
|
|
44
|
+
const r = await call<Asset>("POST", `${rel.upload_url.replace(/\{[^}]*\}$/, "")}?name=${encodeURIComponent(name)}`, undefined, { bytes, type });
|
|
45
|
+
if (r.status !== 201) fail(`uploading ${name}`, r);
|
|
46
|
+
console.log(`uploaded ${name} (${bytes.length} bytes)${existing ? " (replaced)" : ""}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# The release flow (docs/releasing.md) as one script, run by Cloudflare Workers Builds (or any machine):
|
|
3
|
+
# release-pr keeps the "chore(master): release X.Y.Z" pull request up to date (every push to master)
|
|
4
|
+
# github-release tags vX.Y.Z and creates the GitHub release with the compiled notes once that PR is merged
|
|
5
|
+
# publish when release v<package.json version> exists and npm lacks that version: check, unit and cloud-rest
|
|
6
|
+
# tests, pack, smoke install, npm publish (provenance only where GitHub Actions' OIDC token exists),
|
|
7
|
+
# GitHub Packages, the tarball on the release
|
|
8
|
+
# executables when that release lacks checksums.txt: every platform, the exe smoke, the archives and checksums on
|
|
9
|
+
# the release, the notes opened with the `./voidbase update` hint
|
|
10
|
+
# Idempotent: a re-run after a partial failure does only what is still missing. Steps are recorded for the status page
|
|
11
|
+
# (ci/public, kind release).
|
|
12
|
+
# scripts/release.sh [--dry-run] [--tag vX.Y.Z] [--no-pr] [--hot]
|
|
13
|
+
# --dry-run everything up to the actions: release-please in dry-run mode, npm publish --dry-run, no uploads
|
|
14
|
+
# --tag publish a release cut by hand (skips release-please; the checkout must be that tag)
|
|
15
|
+
# --no-pr skip the release PR refresh (nothing releasable in the push)
|
|
16
|
+
# --hot hot mode: publish to npm, leave the executables to a later normal run
|
|
17
|
+
# scripts/ci.sh runs it as the last step of a CI build (CI_NESTED=1: no reset, no cache, no page of its own).
|
|
18
|
+
# Environment: GH_TOKEN (contents + pull requests write on the repository), NPM_TOKEN, GH_PACKAGES_TOKEN (optional: the
|
|
19
|
+
# Actions token or a classic PAT with write:packages; fine-grained tokens cannot publish packages), GITHUB_REPOSITORY.
|
|
20
|
+
set -uo pipefail
|
|
21
|
+
cd "$(dirname "$0")/.."; ROOT="$PWD"
|
|
22
|
+
. scripts/ci-lib.sh
|
|
23
|
+
DRY=""; TAG=""; PR=1; HOT=""
|
|
24
|
+
while [ $# -gt 0 ]; do case "$1" in --dry-run) DRY=1 ;; --tag) TAG="$2"; shift ;; --no-pr) PR=0 ;; --hot) HOT=1 ;; *) echo "unknown option $1"; exit 2 ;; esac; shift; done
|
|
25
|
+
BACKEND=$(ci_backend); export CI_BACKEND_NAME="$BACKEND"
|
|
26
|
+
REPO="${GITHUB_REPOSITORY:-voidbase-cloud/voidbase}"; export GITHUB_REPOSITORY="$REPO"
|
|
27
|
+
BRANCH="${WORKERS_CI_BRANCH:-${GITHUB_REF_NAME:-$(git rev-parse --abbrev-ref HEAD 2>/dev/null)}}"
|
|
28
|
+
VERSION=$(node -p "require('./package.json').version"); PKG="@voidbase-cloud/voidbase"
|
|
29
|
+
RP=(bunx release-please@17.11.2); RP_ARGS=(--repo-url "$REPO" --token "${GH_TOKEN:-}" --target-branch master --config-file release-please-config.json --manifest-file .release-please-manifest.json)
|
|
30
|
+
CI_CACHE_DIR="$(ci_cache_dir)"; export CI_CACHE_DIR; mkdir -p "$CI_CACHE_DIR"
|
|
31
|
+
outputs() { if [ -n "${GITHUB_OUTPUT:-}" ]; then printf '%s\n' "$@" >> "$GITHUB_OUTPUT"; fi; }
|
|
32
|
+
if [ -z "${CI_NESTED:-}" ]; then
|
|
33
|
+
rm -rf .void/ci-logs "$CI_STEPS_TSV"; mkdir -p .void/ci-logs
|
|
34
|
+
finish() { local rc=$?; render_status --kind release || true; if [ "$rc" = 0 ]; then echo "RELEASE FLOW DONE"; else echo "RELEASE FLOW FAILED (exit $rc)"; fi; }
|
|
35
|
+
trap finish EXIT
|
|
36
|
+
fi
|
|
37
|
+
echo "release flow on $BACKEND: $REPO, branch $BRANCH, package $VERSION${TAG:+, tag $TAG}${DRY:+, dry run}${HOT:+, hot mode (npm only)}"
|
|
38
|
+
|
|
39
|
+
if [ -z "${CI_NESTED:-}" ]; then step install bun install --frozen-lockfile || exit 1; step cache-restore ./scripts/ci-cache.sh restore || true; fi
|
|
40
|
+
|
|
41
|
+
# release-please, on master only, unless a release cut by hand is being published; without GH_TOKEN the release PR
|
|
42
|
+
# cannot be maintained, which only matters once something needs publishing (checked below without a token)
|
|
43
|
+
if [ -z "${GH_TOKEN:-}" ]; then echo "GH_TOKEN is not set: release-please skipped, publishing would fail"; skip_step release-pr "no GH_TOKEN"; skip_step github-release "no GH_TOKEN"
|
|
44
|
+
elif [ -z "$TAG" ] && [ "$BRANCH" = master ]; then
|
|
45
|
+
if [ "$PR" = 1 ]; then step release-pr "${RP[@]}" release-pr "${RP_ARGS[@]}" ${DRY:+--dry-run} || exit 1; else skip_step release-pr; fi
|
|
46
|
+
step github-release "${RP[@]}" github-release "${RP_ARGS[@]}" ${DRY:+--dry-run} || exit 1
|
|
47
|
+
else skip_step release-pr; skip_step github-release; fi
|
|
48
|
+
|
|
49
|
+
TAG="${TAG:-v$VERSION}"
|
|
50
|
+
[ "$TAG" = "v$VERSION" ] || { echo "tag $TAG does not match package.json version $VERSION"; exit 1; }
|
|
51
|
+
release_json=$(bun scripts/gh-release.ts view "$TAG" 2>/dev/null) || release_json=""
|
|
52
|
+
if [ -z "$release_json" ] && [ -z "$DRY" ]; then echo; echo "no release $TAG: nothing to publish"; outputs "published=false" "executables=false" "tag=$TAG"; exit 0; fi
|
|
53
|
+
echo "release $TAG: ${release_json:-none (dry run continues)}"
|
|
54
|
+
has_asset() { printf '%s' "$release_json" | grep -qF "\"$1\""; }
|
|
55
|
+
|
|
56
|
+
on_npm=0; npm view "$PKG@$VERSION" version >/dev/null 2>&1 && on_npm=1
|
|
57
|
+
publish_npm() {
|
|
58
|
+
bun run check && bun test && bun test/cloud-rest.ts || return 1
|
|
59
|
+
rm -f voidbase-cloud-voidbase-*.tgz; npm pack || return 1
|
|
60
|
+
local tarball; tarball="$PWD/$(ls voidbase-cloud-voidbase-*.tgz)"; ls -la "$tarball"
|
|
61
|
+
local smoke; smoke=$(mktemp -d)
|
|
62
|
+
(cd "$smoke" && bun init -y >/dev/null && bun add "$tarball" && bunx voidbase help | head -n 5 && node -e "const p=require('$PKG/package.json'); if (p.version !== '$VERSION') throw new Error('version mismatch: ' + p.version)") || return 1
|
|
63
|
+
[ -n "${NPM_TOKEN:-}" ] || { echo "NPM_TOKEN is not set"; return 1; }
|
|
64
|
+
# a dry run of a version that is already on npm: npm refuses to "publish over" it even without publishing, so the
|
|
65
|
+
# rehearsal ends here (the real flow skips publishing altogether for a published version)
|
|
66
|
+
if [ -n "$DRY" ] && [ "$on_npm" = 1 ]; then echo "npm publish: $PKG@$VERSION is already published, the dry run skips the publish commands"; return 0; fi
|
|
67
|
+
local npmrc; npmrc=$(mktemp); printf '//registry.npmjs.org/:_authToken=%s\n' "$NPM_TOKEN" > "$npmrc"
|
|
68
|
+
local provenance=""; [ "$BACKEND" = github ] && [ -n "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ] && provenance="--provenance"
|
|
69
|
+
echo "npm publish: provenance ${provenance:-off (only GitHub Actions can mint the OIDC token)}, dry run ${DRY:-no}"
|
|
70
|
+
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "$tarball" --access public $provenance ${DRY:+--dry-run} || { rm -f "$npmrc"; return 1; }
|
|
71
|
+
rm -f "$npmrc"
|
|
72
|
+
if [ -n "${GH_PACKAGES_TOKEN:-}" ]; then
|
|
73
|
+
npmrc=$(mktemp); printf '@voidbase-cloud:registry=https://npm.pkg.github.com\n//npm.pkg.github.com/:_authToken=%s\n' "$GH_PACKAGES_TOKEN" > "$npmrc"
|
|
74
|
+
NPM_CONFIG_USERCONFIG="$npmrc" npm publish "$tarball" --registry=https://npm.pkg.github.com ${DRY:+--dry-run} || echo "GitHub Packages publish failed (npm is the registry of record; continuing)"
|
|
75
|
+
rm -f "$npmrc"
|
|
76
|
+
else echo "GitHub Packages: skipped (no GH_PACKAGES_TOKEN)"; fi
|
|
77
|
+
if [ -z "$DRY" ]; then bun scripts/gh-release.ts upload "$TAG" "$tarball" || return 1; fi
|
|
78
|
+
}
|
|
79
|
+
if [ "$on_npm" = 1 ] && [ -z "$DRY" ]; then skip_step publish; echo "$PKG@$VERSION is already on npm"
|
|
80
|
+
elif [ -z "${GH_TOKEN:-}" ]; then echo "release $TAG needs publishing but GH_TOKEN is not set"; exit 1
|
|
81
|
+
else step publish publish_npm || exit 1; [ -z "$DRY" ] && outputs "published=true"; fi
|
|
82
|
+
|
|
83
|
+
build_executables() {
|
|
84
|
+
. scripts/ci-oracles.sh
|
|
85
|
+
XDG_CACHE_HOME="$CI_CACHE_DIR/xdg" bun run panel:sync || return 1
|
|
86
|
+
bun scripts/build-exe.ts --targets all --out dist/release || return 1
|
|
87
|
+
cat dist/release/checksums.txt
|
|
88
|
+
STARTER_VB_DIR="$STARTER_DIR/pb" bun test/exe-smoke.ts || return 1
|
|
89
|
+
[ -n "$DRY" ] && return 0
|
|
90
|
+
bun scripts/gh-release.ts upload "$TAG" dist/release/*.zip dist/release/checksums.txt || return 1
|
|
91
|
+
# PocketBase's shape: the `./voidbase update` hint first, then the compiled notes (`voidbase update` strips the hint again)
|
|
92
|
+
bun scripts/gh-release.ts body "$TAG" > .void/release-body.md || return 1
|
|
93
|
+
if ! grep -qF 'To update the prebuilt executable you can run `./voidbase update`' .void/release-body.md; then
|
|
94
|
+
{ printf '> _To update the prebuilt executable you can run `./voidbase update`._\n\n'; cat .void/release-body.md; } > .void/release-notes.md
|
|
95
|
+
bun scripts/gh-release.ts notes "$TAG" .void/release-notes.md || return 1
|
|
96
|
+
fi
|
|
97
|
+
}
|
|
98
|
+
if has_asset checksums.txt && [ -z "$DRY" ]; then skip_step executables; echo "release $TAG already has its executables"
|
|
99
|
+
elif [ -n "$HOT" ]; then skip_step executables "hot mode: npm only, a normal run adds them"; echo "release $TAG: executables left to a normal run (hot mode)"
|
|
100
|
+
elif [ -z "${GH_TOKEN:-}" ]; then echo "release $TAG needs its executables but GH_TOKEN is not set"; exit 1
|
|
101
|
+
else step executables build_executables || exit 1; [ -z "$DRY" ] && outputs "executables=true"; fi
|
|
102
|
+
outputs "tag=$TAG"
|
|
103
|
+
if [ -z "${CI_NESTED:-}" ]; then step cache-save ./scripts/ci-cache.sh save || true; fi
|
|
104
|
+
echo; echo "release $TAG: done${DRY:+ (dry run)}${HOT:+ (hot mode)}"
|
|
@@ -11,8 +11,13 @@ USER_EMAIL="${REFERENCE_USER_EMAIL:-user@example.com}"; USER_PASSWORD="${REFEREN
|
|
|
11
11
|
mkdir -p "$DIR"; STARTER="$(cd "$STARTER" && pwd)"
|
|
12
12
|
if [ ! -x "$DIR/pocketbase" ] || [ "$("$DIR/pocketbase" --version 2>/dev/null)" != "pocketbase version $VERSION" ]; then
|
|
13
13
|
arch="linux_amd64"; case "$(uname -m)" in aarch64|arm64) arch="linux_arm64";; esac
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
cached="${CI_CACHE_DIR:+$CI_CACHE_DIR/archives/pocketbase_${VERSION}_${arch}.zip}"
|
|
15
|
+
if [ -n "$cached" ] && [ -f "$cached" ]; then echo "pocketbase $VERSION ($arch) from the cache"; cp "$cached" "$DIR/pb.zip"
|
|
16
|
+
else
|
|
17
|
+
echo "downloading pocketbase $VERSION ($arch)"
|
|
18
|
+
curl -sSL "https://github.com/pocketbase/pocketbase/releases/download/v${VERSION}/pocketbase_${VERSION}_${arch}.zip" -o "$DIR/pb.zip"
|
|
19
|
+
if [ -n "$cached" ]; then mkdir -p "$CI_CACHE_DIR/archives" && cp "$DIR/pb.zip" "$cached"; fi
|
|
20
|
+
fi
|
|
16
21
|
(cd "$DIR" && unzip -oq pb.zip pocketbase && rm pb.zip)
|
|
17
22
|
fi
|
|
18
23
|
PB=("$DIR/pocketbase" "--dir" "$DIR/pb_data" "--migrationsDir" "$STARTER/pb/pb_migrations" "--hooksDir" "$STARTER/pb/pb_hooks")
|
package/scripts/sync-app.ts
CHANGED
|
@@ -15,6 +15,7 @@ mkdirSync(dest, { recursive: true });
|
|
|
15
15
|
for (const entry of readdirSync(dest)) if (entry !== "_") rmSync(`${dest}/${entry}`, { recursive: true, force: true });
|
|
16
16
|
for (const entry of readdirSync(src)) {
|
|
17
17
|
if (entry === "_") { console.warn("skipping the app's /_ directory: that path belongs to the admin panel"); continue; }
|
|
18
|
+
if (entry === "_redirects") continue; // becomes void.json routing.redirects (host-aware); Cloudflare's asset layer would reject host sources
|
|
18
19
|
cpSync(`${src}/${entry}`, `${dest}/${entry}`, { recursive: true });
|
|
19
20
|
}
|
|
20
21
|
console.log(`synced app ${src} -> ${dest} (${statSync(`${dest}/index.html`).size} bytes index.html)`);
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Bundles the Void app's server code into one file that a pb_hooks sandbox can run.
|
|
2
|
+
//
|
|
3
|
+
// A hook file has no module resolution: its `require` reaches only its sibling hook files, and nothing else. Void's
|
|
4
|
+
// routes import npm packages (`void/response`, `void/db`, Drizzle), so they have to arrive pre-bundled. Two details
|
|
5
|
+
// make that work:
|
|
6
|
+
// - `node:async_hooks` cannot be bundled and cannot be required at runtime inside the sandbox, so the build
|
|
7
|
+
// rewrites it to read `globalThis.AsyncLocalStorage`, which voidbase's hook runtime publishes on both runtimes
|
|
8
|
+
// (src/server/hooks/runtime.ts);
|
|
9
|
+
// - the output carries the `// voidbase:raw` pragma, which tells the hook compiler to leave it alone instead of
|
|
10
|
+
// running the await-insertion transform over it (hooks-plugin.ts).
|
|
11
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
12
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
13
|
+
import type { BunPlugin } from "bun";
|
|
14
|
+
|
|
15
|
+
export const RAW_PRAGMA = "// voidbase:raw";
|
|
16
|
+
|
|
17
|
+
// A hook cannot require a node builtin: the sandbox resolves only its sibling hook files. Two are handled here and
|
|
18
|
+
// anything else fails the build, loudly, rather than at the first request.
|
|
19
|
+
const ASYNC_HOOKS_SHIM = `const g = globalThis;
|
|
20
|
+
if (!g.AsyncLocalStorage) throw new Error("voidbase: AsyncLocalStorage is missing; the hook runtime publishes it");
|
|
21
|
+
export const AsyncLocalStorage = g.AsyncLocalStorage;
|
|
22
|
+
export default { AsyncLocalStorage };`;
|
|
23
|
+
// Bun's CommonJS output opens with an unused \`require("node:module")\` for interop; an empty module satisfies it.
|
|
24
|
+
const MODULE_SHIM = `export const createRequire = () => { throw new Error("voidbase: createRequire is not available inside a hook"); };
|
|
25
|
+
export default { createRequire };`;
|
|
26
|
+
|
|
27
|
+
const builtinShims: BunPlugin = {
|
|
28
|
+
name: "voidbase-node-builtins",
|
|
29
|
+
setup(build) {
|
|
30
|
+
build.onResolve({ filter: /^(node:)?async_hooks$/ }, () => ({ path: "async_hooks", namespace: "voidbase-shim" }));
|
|
31
|
+
build.onResolve({ filter: /^(node:)?module$/ }, () => ({ path: "module", namespace: "voidbase-shim" }));
|
|
32
|
+
build.onResolve({ filter: /^node:/ }, (args) => {
|
|
33
|
+
throw new Error(`voidbase: ${args.path} cannot be used in code compiled into pb_hooks (imported by ${args.importer}). A hook has no module resolution, so nothing reachable from routes/, middleware/, crons/ or queues/ may import a node builtin.`);
|
|
34
|
+
});
|
|
35
|
+
build.onLoad({ filter: /.*/, namespace: "voidbase-shim" }, (args) => ({
|
|
36
|
+
contents: args.path === "async_hooks" ? ASYNC_HOOKS_SHIM : MODULE_SHIM,
|
|
37
|
+
loader: "js",
|
|
38
|
+
}));
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The aliases the bundle needs, which Vite would otherwise apply:
|
|
44
|
+
* - `void` itself resolves to the package entry, which carries the Vite plugin and its Cloudflare dependency. A
|
|
45
|
+
* route only ever wants the runtime half, so it is rewritten to `void/handler`.
|
|
46
|
+
* - the tsconfig paths Void generates (`@schema`, and the shims this adapter points `void/db` and `void/queues`
|
|
47
|
+
* at) are compiler-only, so the bundler is told about them explicitly.
|
|
48
|
+
*/
|
|
49
|
+
const CODE_EXT = ["", ".ts", ".tsx", ".mts", ".js", ".jsx", ".mjs", "/index.ts", "/index.tsx", "/index.js"];
|
|
50
|
+
// the bare "" comes first so `@schema` -> db/schema.ts wins over a `db/schema/` directory, but an alias whose target
|
|
51
|
+
// is a directory (`@/shared` -> src/shared) has to fall through to its index file rather than resolve to the folder
|
|
52
|
+
const isFile = (p: string) => { try { return statSync(p).isFile(); } catch { return false; } };
|
|
53
|
+
const asFile = (base: string): string | null => CODE_EXT.map((e) => base + e).find(isFile) ?? null;
|
|
54
|
+
|
|
55
|
+
/** every `paths` entry of a tsconfig fragment, with its targets resolved against that file */
|
|
56
|
+
function pathsOf(file: string): Record<string, string[]> {
|
|
57
|
+
if (!existsSync(file)) return {};
|
|
58
|
+
try {
|
|
59
|
+
const cfg = JSON.parse(readFileSync(file, "utf8")) as { compilerOptions?: { paths?: Record<string, string[]> } };
|
|
60
|
+
const out: Record<string, string[]> = {};
|
|
61
|
+
for (const [key, targets] of Object.entries(cfg.compilerOptions?.paths ?? {})) {
|
|
62
|
+
out[key] = targets.map((t) => (isAbsolute(t) ? t : resolve(dirname(file), t)));
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
} catch { return {}; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function aliasPlugin(root: string): BunPlugin {
|
|
69
|
+
// the adapter's own fragment first (it repoints void/db and void/queues at the shims), then Void's, then the
|
|
70
|
+
// project's own tsconfig; `@schema` falls back to Void's convention when no fragment has been generated yet
|
|
71
|
+
const paths: Record<string, string[]> = {
|
|
72
|
+
...pathsOf(join(root, "tsconfig.json")),
|
|
73
|
+
...pathsOf(join(root, ".void", "tsconfig.json")),
|
|
74
|
+
...pathsOf(join(root, ".voidbase", "tsconfig.json")),
|
|
75
|
+
};
|
|
76
|
+
if (!paths["@schema"] && existsSync(join(root, "db", "schema.ts"))) paths["@schema"] = [join(root, "db", "schema.ts")];
|
|
77
|
+
|
|
78
|
+
const exact = Object.entries(paths).filter(([k]) => !k.includes("*"));
|
|
79
|
+
const wildcard = Object.entries(paths).filter(([k]) => k.endsWith("/*")).map(([k, v]) => [k.slice(0, -1), v] as const);
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
name: "voidbase-aliases",
|
|
83
|
+
setup(build) {
|
|
84
|
+
// `void` itself is the package entry, which carries the Vite plugin; a route only wants the runtime half
|
|
85
|
+
build.onResolve({ filter: /^void$/ }, () => ({ path: Bun.resolveSync("void/handler", root) }));
|
|
86
|
+
build.onResolve({ filter: /.*/ }, (args) => {
|
|
87
|
+
for (const [key, targets] of exact) {
|
|
88
|
+
if (args.path !== key) continue;
|
|
89
|
+
const hit = targets.map(asFile).find(Boolean);
|
|
90
|
+
if (hit) return { path: hit };
|
|
91
|
+
}
|
|
92
|
+
for (const [prefix, targets] of wildcard) {
|
|
93
|
+
if (!args.path.startsWith(prefix)) continue;
|
|
94
|
+
const rest = args.path.slice(prefix.length);
|
|
95
|
+
const hit = targets.map((t) => asFile(t.replace(/\*$/, "") + rest)).find(Boolean);
|
|
96
|
+
if (hit) return { path: hit };
|
|
97
|
+
}
|
|
98
|
+
return undefined; // let Bun resolve it
|
|
99
|
+
});
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export interface BundleResult { code: string; bytes: number }
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Builds `entry` (a generated module exporting `register(api)`) into a single CommonJS file.
|
|
108
|
+
* Throws with Bun's own diagnostics when the app's code does not bundle.
|
|
109
|
+
*/
|
|
110
|
+
export async function bundleHookApp(entry: string, root: string): Promise<BundleResult> {
|
|
111
|
+
if (!existsSync(entry)) throw new Error(`voidbase: bundle entry ${entry} does not exist`);
|
|
112
|
+
const built = await Bun.build({
|
|
113
|
+
entrypoints: [resolve(entry)],
|
|
114
|
+
root: resolve(root),
|
|
115
|
+
target: "node", // Bun and workerd both run the result; the shim keeps node builtins out of it
|
|
116
|
+
format: "cjs",
|
|
117
|
+
minify: false, // the file is read when something goes wrong in production
|
|
118
|
+
sourcemap: "none",
|
|
119
|
+
plugins: [builtinShims, aliasPlugin(resolve(root))],
|
|
120
|
+
throw: false, // the diagnostics below are more useful than Bun's AggregateError
|
|
121
|
+
});
|
|
122
|
+
if (!built.success) {
|
|
123
|
+
const why = built.logs.map((l) => `${l.level}: ${l.message}${(l as { position?: { file?: string } }).position?.file ? ` (${(l as { position?: { file?: string } }).position!.file})` : ""}`).join("\n");
|
|
124
|
+
throw new Error(`voidbase: could not bundle the app's server code for pb_hooks.\n${why}`);
|
|
125
|
+
}
|
|
126
|
+
const [artifact] = built.outputs;
|
|
127
|
+
if (!artifact) throw new Error("voidbase: the bundler produced no output");
|
|
128
|
+
const code = await artifact.text();
|
|
129
|
+
return { code: `${RAW_PRAGMA}\n// Generated by voidbase's Void adapter from routes/, middleware/, crons/ and queues/.\n${code}`, bytes: code.length };
|
|
130
|
+
}
|