@voidbase-cloud/voidbase 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/README.md +15 -7
  3. package/bin/voidbase.ts +58 -6
  4. package/docs/adapter.md +271 -0
  5. package/docs/ci.md +195 -0
  6. package/docs/deploy.md +60 -5
  7. package/docs/releasing.md +39 -31
  8. package/hooks-plugin.ts +15 -4
  9. package/package.json +9 -4
  10. package/routes/api/[...path].ts +0 -5
  11. package/scripts/cf-builds.ts +228 -0
  12. package/scripts/ci-browser.sh +60 -0
  13. package/scripts/ci-cache.sh +40 -0
  14. package/scripts/ci-lib.sh +40 -0
  15. package/scripts/ci-oracles.sh +19 -0
  16. package/scripts/ci-plan.ts +270 -0
  17. package/scripts/ci-status.ts +126 -0
  18. package/scripts/ci-suites.sh +11 -1
  19. package/scripts/ci.sh +188 -0
  20. package/scripts/gh-release.ts +48 -0
  21. package/scripts/release.sh +104 -0
  22. package/scripts/seed-reference.sh +7 -2
  23. package/scripts/sync-app.ts +1 -0
  24. package/src/adapter/bundle.ts +130 -0
  25. package/src/adapter/codegen.ts +269 -0
  26. package/src/adapter/index.ts +6 -0
  27. package/src/adapter/plugin.ts +117 -0
  28. package/src/adapter/runtime.ts +325 -0
  29. package/src/adapter/scan.ts +276 -0
  30. package/src/cloud/rest.ts +10 -2
  31. package/src/node/assets.ts +7 -1
  32. package/src/node/cloud-init.ts +14 -0
  33. package/src/node/deploy-cf.ts +113 -16
  34. package/src/node/secrets.ts +169 -0
  35. package/src/node/serve.ts +15 -2
  36. package/src/server/api.ts +7 -2
  37. package/src/server/app.ts +6 -1
  38. package/src/server/hooks/index.ts +27 -1
  39. package/src/server/hooks/migrations.ts +6 -1
  40. package/src/server/hooks/runtime.ts +10 -2
  41. package/src/server/jobs.ts +3 -1
  42. package/src/server/webauthn.ts +23 -6
  43. package/tsconfig.json +4 -0
  44. package/tsconfig.node.json +2 -1
@@ -0,0 +1,269 @@
1
+ // Turns the manifest into a whole voidbase app under `.voidbase/`: PocketBase's minimal layout (main.ts,
2
+ // package.json, pb_hooks/, pb_migrations/, pb_public/, pb_data/) plus the generated glue that imports the Void
3
+ // app's own modules. The project root stays a plain Void app and `.voidbase/` is git-ignored build output.
4
+ import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
5
+ import { dirname, join, resolve } from "node:path";
6
+ import type { VoidManifest } from "./scan";
7
+ import type { SecretsDeclaration } from "../node/secrets";
8
+
9
+ const BANNER = "// Generated by voidbase's Void adapter. Do not edit: `vite build` (or `voidbase adapt`) rewrites it.";
10
+ /** the import specifier the generated code uses for the adapter runtime; overridable so this repo can test itself */
11
+ export const ADAPTER_PACKAGE = "@voidbase-cloud/voidbase";
12
+
13
+ /** from inside .voidbase/, the app's own files are one directory up */
14
+ const importPath = (file: string) => "../" + file.replace(/\.(m?[tj]sx?)$/, "");
15
+ const ident = (prefix: string, i: number) => `${prefix}${i}`;
16
+
17
+ export interface GenerateOptions {
18
+ /** package name the generated files import voidbase from */
19
+ pkg?: string;
20
+ }
21
+
22
+ /** The bundle entry: imports the app's own modules and hands them to the hook runtime. Bundled into pb_hooks. */
23
+ export function generateServerModule(m: VoidManifest, opts: GenerateOptions = {}): string {
24
+ const pkg = opts.pkg ?? ADAPTER_PACKAGE;
25
+ const lines: string[] = [BANNER, "//", "// The project's routes, middleware, vb_hooks, crons and queues, as one module for the generated pb_hooks bundle.", ""];
26
+ lines.push(`import { mountVoidApp } from "${pkg}/adapter";`);
27
+ m.routes.forEach((r, i) => lines.push(`import * as ${ident("route", i)} from "${importPath(r.file)}";`));
28
+ m.middleware.forEach((x, i) => lines.push(`import ${ident("middleware", i)} from "${importPath(x.file)}";`));
29
+ m.hooks.forEach((x, i) => lines.push(`import ${ident("hook", i)} from "${importPath(x.file)}";`));
30
+ m.crons.forEach((x, i) => lines.push(`import * as ${ident("cron", i)} from "${importPath(x.file)}";`));
31
+ m.queues.forEach((x, i) => lines.push(`import ${ident("queue", i)} from "${importPath(x.file)}";`));
32
+ lines.push("", "export function register(): void {", " mountVoidApp({");
33
+
34
+ lines.push(" routes: [");
35
+ m.routes.forEach((r, i) => {
36
+ const splat = r.splat ? `, splat: ${JSON.stringify(r.splat)}` : "";
37
+ lines.push(` { url: ${JSON.stringify(r.url)}, hookPath: ${JSON.stringify(r.hookPath)}, methods: ${JSON.stringify(r.methods)}, params: ${JSON.stringify(r.params)}${splat}, mod: ${ident("route", i)} },`);
38
+ });
39
+ lines.push(" ],");
40
+
41
+ lines.push(" hooks: [");
42
+ m.hooks.forEach((x, i) => lines.push(` { hook: ${JSON.stringify(x.hook)}, handler: ${ident("hook", i)} },`));
43
+ lines.push(" ],");
44
+
45
+ lines.push(` middleware: [${m.middleware.map((_, i) => ident("middleware", i)).join(", ")}],`);
46
+
47
+ lines.push(" crons: [");
48
+ m.crons.forEach((c, i) => lines.push(` { name: ${JSON.stringify(c.name)}, expr: ${ident("cron", i)}.cron, handler: ${ident("cron", i)}.default },`));
49
+ lines.push(" ],");
50
+
51
+ lines.push(" queues: [");
52
+ m.queues.forEach((q, i) => lines.push(` { name: ${JSON.stringify(q.name)}, binding: ${JSON.stringify(q.binding)}, consumer: ${ident("queue", i)} },`));
53
+ lines.push(" ],");
54
+
55
+ lines.push(" });", "}", "");
56
+ return lines.join("\n");
57
+ }
58
+
59
+ /** The pb_hooks file that registers the bundle: the one place the hook globals are in scope. */
60
+ export function generateHookWrapper(): string {
61
+ return `${BANNER}
62
+ // The project's routes, middleware, vb_hooks, crons and queues. The code is in void-app.js beside this file, bundled
63
+ // because a hook cannot import from npm; this is where the hook globals it needs are in scope.
64
+ //
65
+ // \`__g\` is the object every hook file is handed its globals from: $app, $apis, routerAdd, routerUse, cronAdd, the
66
+ // error classes and every on* event registrar. Publishing it is what makes \`pb\` work inside the bundle, and it is
67
+ // published *before* the bundle is required so a module can register an event hook while it is initialising.
68
+ globalThis.__voidbaseHooks = __g;
69
+
70
+ require(\`\${__hooks}/void-app.js\`).register();
71
+ `;
72
+ }
73
+
74
+ /** The generated app's entry: the app's own voidbase extensions, and a Bun runner. */
75
+ export function generateMainEntry(m: VoidManifest, opts: GenerateOptions = {}): string {
76
+ const pkg = opts.pkg ?? ADAPTER_PACKAGE;
77
+ const lines = [
78
+ BANNER,
79
+ "//",
80
+ "// The voidbase app this Void project builds into: `bun .voidbase/main.ts` runs it, `voidbase deploy` from this",
81
+ "// directory ships it. Edit the project, not this file.",
82
+ "//",
83
+ "// The whole app -- routes/, middleware/, crons/ and queues/ -- is compiled into pb_hooks, so nothing but the",
84
+ "// runner is left here.",
85
+ `import type { VoidbaseApp } from "${pkg}";`,
86
+ ];
87
+ lines.push("", "export function register(_app: VoidbaseApp) {");
88
+ lines.push(" // nothing to register here: routes/, middleware/, crons/ and queues/ are the app's server code, and");
89
+ lines.push(" // they are compiled into pb_hooks. Everything under src/ reaches PocketBase through them.");
90
+ lines.push("}", "");
91
+ lines.push(`if (import.meta.main) {
92
+ // the Bun runtime, imported dynamically (and hidden from the bundler) because \`voidbase deploy\` composes this
93
+ // file into the Worker for register() only, and the runtime entry pulls in bun:sqlite and the filesystem shims
94
+ const runtime = "${pkg}";
95
+ const { voidbase, parseServeArgs } = (await import(/* @vite-ignore */ runtime)) as typeof import("${pkg}");
96
+ // this app's directories sit next to this file, so it runs the same from any working directory
97
+ const flags = parseServeArgs(process.argv.slice(2).filter((a) => a !== "serve"));
98
+ for (const key of Object.keys(flags) as (keyof typeof flags)[]) if (flags[key] === undefined) delete flags[key];
99
+ const app = await voidbase({
100
+ dir: \`\${import.meta.dir}/pb_data\`,
101
+ hooksDir: process.env.VOIDBASE_HOOKS_DIR ?? \`\${import.meta.dir}/pb_hooks\`,
102
+ migrationsDir: process.env.VOIDBASE_MIGRATIONS_DIR ?? \`\${import.meta.dir}/pb_migrations\`,
103
+ publicDir: \`\${import.meta.dir}/pb_public\`,
104
+ ...flags,
105
+ });
106
+ register(app);
107
+ await app.start();
108
+ }
109
+ `);
110
+ return lines.join("\n");
111
+ }
112
+
113
+ /** The generated app's package.json: enough for voidbase deploy to read the dependency spec and for Bun to run it. */
114
+ export function generatePackageJson(m: VoidManifest, opts: GenerateOptions = {}): string {
115
+ const pkg = opts.pkg ?? ADAPTER_PACKAGE;
116
+ let spec = "latest";
117
+ try {
118
+ const parent = JSON.parse(readFileSync(join(m.root, "package.json"), "utf8")) as { name?: string; dependencies?: Record<string, string>; devDependencies?: Record<string, string> };
119
+ spec = parent.dependencies?.[pkg] ?? parent.devDependencies?.[pkg] ?? spec;
120
+ return JSON.stringify({ name: `${parent.name ?? "app"}-voidbase`, private: true, type: "module", main: "main.ts", dependencies: { [pkg]: spec } }, null, 2) + "\n";
121
+ } catch {
122
+ return JSON.stringify({ name: "app-voidbase", private: true, type: "module", main: "main.ts", dependencies: { [pkg]: spec } }, null, 2) + "\n";
123
+ }
124
+ }
125
+
126
+ /** Drizzle separates statements with its own marker; without it fall back to semicolons at end of line. */
127
+ export function splitSql(sql: string): string[] {
128
+ const source = sql.includes("--> statement-breakpoint") ? sql.split("--> statement-breakpoint") : sql.split(/;\s*$/m);
129
+ return source.map((s) => s.replace(/^\s*--.*$/gm, "").trim().replace(/;$/, "")).filter(Boolean);
130
+ }
131
+
132
+ /** A Drizzle .sql migration as a PocketBase-style migration, so both runtimes apply it through the same runner. */
133
+ export function generateMigration(name: string, sql: string): string {
134
+ const statements = splitSql(sql);
135
+ return `${BANNER}
136
+ // Generated from db/migrations/${name}.sql (Drizzle). voidbase records it in _pbMigrations like any other migration.
137
+ migrate(async (app) => {
138
+ ${statements.map((s) => ` await app.execSQL(${JSON.stringify(s)});`).join("\n")}
139
+ }, async (_app) => {
140
+ // Drizzle migrations carry no down step; roll back with a new migration.
141
+ });
142
+ `;
143
+ }
144
+
145
+ // Void's codegen maps void/db and void/queues to declaration files in .void/ (schema-aware types for tsc). Bun
146
+ // honours tsconfig paths at runtime too, so under voidbase those imports would resolve to a .d.ts and export
147
+ // nothing. These shims take the values from the published runtime and the types from Void's declarations, and the
148
+ // generated .voidbase/tsconfig.json points the two mappings at them, so both tsc and Bun get what they need.
149
+ const DB_REEXPORTS = "and, asc, avg, between, count, createDb, desc, eq, exists, gt, gte, ilike, inArray, isNotNull, isNull, like, lt, lte, max, min, ne, not, notBetween, notExists, notInArray, notLike, or, sql, sum";
150
+
151
+ export function generateDbShim(): string {
152
+ // void/_db is the same runtime module under a specifier the path mapping does not cover, so the shim can import
153
+ // it without importing itself; the schema-aware types still come from Void's own declaration.
154
+ return `${BANNER}
155
+ // void/db for a voidbase app: Drizzle over voidbase's D1 binding, typed by .void/db.d.ts.
156
+ import type * as Typed from "../.void/db";
157
+ import { db as runtimeDb } from "void/_db";
158
+
159
+ export { ${DB_REEXPORTS} } from "void/_db";
160
+ export const db: typeof Typed.db = runtimeDb as never;
161
+ `;
162
+ }
163
+
164
+ export function generateQueuesShim(): string {
165
+ // Void has no unmapped alias for the queue producers, so the shim is the same one-line proxy Void ships: a
166
+ // binding lookup per queue name. The adapter puts those bindings on the env (src/adapter/runtime.ts).
167
+ return `${BANNER}
168
+ // void/queues for a voidbase app: the producers the adapter overlays on the env, typed by .void/queues.d.ts.
169
+ import { requireRuntimeBinding } from "void/_env";
170
+ import type * as Typed from "../.void/queues";
171
+
172
+ export const queues: typeof Typed.queues = new Proxy({}, {
173
+ get: (_target, name: string) => requireRuntimeBinding(\`QUEUE_\${name.toUpperCase().replace(/[^A-Z0-9]/g, "_")}\`),
174
+ }) as never;
175
+ `;
176
+ }
177
+
178
+ /** Void's tsconfig fragment with the two runtime-breaking mappings repointed at the shims. */
179
+ export function generateTsconfig(root: string): string | null {
180
+ const voidTs = join(root, ".void", "tsconfig.json");
181
+ if (!existsSync(voidTs)) return null;
182
+ const base = JSON.parse(readFileSync(voidTs, "utf8")) as { compilerOptions?: { paths?: Record<string, string[]> } };
183
+ const paths: Record<string, string[]> = {};
184
+ for (const [key, targets] of Object.entries(base.compilerOptions?.paths ?? {})) {
185
+ // .void and .voidbase sit at the same depth, so a target of the base config is rewritten once
186
+ paths[key] = targets.map((t) => (t.startsWith("./") ? `../.void/${t.slice(2)}` : t));
187
+ }
188
+ if (paths["void/db"]) paths["void/db"] = ["./shim-db.ts"];
189
+ if (paths["void/queues"]) paths["void/queues"] = ["./shim-queues.ts"];
190
+ return JSON.stringify({ extends: "../.void/tsconfig.json", compilerOptions: { paths } }, null, 2) + "\n";
191
+ }
192
+
193
+ export interface WriteResult { written: string[]; removed: string[] }
194
+
195
+ const OUT = ".voidbase";
196
+
197
+ /** whether anything under Void's server conventions has to run */
198
+ export const hasServerCode = (m: VoidManifest) => !!(m.routes.length || m.middleware.length || m.crons.length || m.queues.length);
199
+
200
+ /** Writes the whole generated app under `<root>/.voidbase`. Everything in there is build output. */
201
+ /** pb_secrets/main.pb.js: what vb_secrets/main.ts declared, as the declaration `voidbase deploy` and `voidbase serve` read. */
202
+ export function generateSecretsDeclaration(d: SecretsDeclaration): string {
203
+ const lines = [BANNER, "//", `// The secrets the app declares in ${d.file}. Read by voidbase serve and voidbase deploy, never run; the values`, "// live in secrets.json beside this file (git-ignored) and, once deployed, as the Worker's secrets.", "secrets({"];
204
+ for (const n of d.names) lines.push(` ${n}: ${JSON.stringify(d.descriptions[n] ?? "")},`);
205
+ lines.push("});", "");
206
+ return lines.join("\n");
207
+ }
208
+
209
+ export function writeVoidbaseApp(m: VoidManifest, opts: GenerateOptions & { migrations?: boolean } = {}): WriteResult {
210
+ const written: string[] = [];
211
+ const removed: string[] = [];
212
+ const put = (rel: string, body: string) => {
213
+ const full = join(m.root, OUT, rel);
214
+ mkdirSync(dirname(full), { recursive: true });
215
+ if (!existsSync(full) || readFileSync(full, "utf8") !== body) writeFileSync(full, body);
216
+ written.push(`${OUT}/${rel}`);
217
+ };
218
+ const drop = (rel: string) => {
219
+ const full = join(m.root, OUT, rel);
220
+ if (!existsSync(full)) return;
221
+ rmSync(full, { recursive: true, force: true });
222
+ removed.push(`${OUT}/${rel}`);
223
+ };
224
+
225
+ // PocketBase's minimal layout, generated whole: https://pocketbase.io/docs/going-to-production/#minimal-setup
226
+ put("main.ts", generateMainEntry(m, opts));
227
+ put("package.json", generatePackageJson(m, opts));
228
+ put(".gitignore", "# the generated voidbase app is build output; this is what it makes at runtime\npb_data/\n# the values of the secrets vb_secrets/main.ts declares (copied from vb_secrets/secrets.json)\npb_secrets/secrets.json\n");
229
+
230
+ // the tsconfig fragment the project's own tsconfig extends, and the shims it points at
231
+ const tsconfig = generateTsconfig(m.root);
232
+ if (tsconfig) {
233
+ put("tsconfig.json", tsconfig);
234
+ if (/"void\/db"/.test(tsconfig)) put("shim-db.ts", generateDbShim());
235
+ if (/"void\/queues"/.test(tsconfig)) put("shim-queues.ts", generateQueuesShim());
236
+ }
237
+
238
+ // routes/, middleware/, crons/ and queues/ become a pb_hooks bundle; this is the module the bundler builds
239
+ drop("pb_hooks");
240
+ if (hasServerCode(m)) put("void-entry.ts", generateServerModule(m, opts));
241
+ else drop("void-entry.ts");
242
+
243
+ // pb_migrations: the project's vb_migrations/, plus one per Drizzle migration
244
+ const migrationsOut = join(m.root, OUT, "pb_migrations");
245
+ drop("pb_migrations");
246
+ if (m.extras.migrationsDir) {
247
+ cpSync(join(m.root, m.extras.migrationsDir), migrationsOut, { recursive: true });
248
+ written.push(`${OUT}/pb_migrations`);
249
+ removed.pop();
250
+ }
251
+ if (opts.migrations !== false && m.migrations.length) {
252
+ mkdirSync(migrationsOut, { recursive: true });
253
+ for (const mig of m.migrations) {
254
+ const sql = readFileSync(resolve(m.root, mig.file), "utf8");
255
+ put(`pb_migrations/${mig.name}.void.js`, generateMigration(mig.name, sql));
256
+ }
257
+ }
258
+ if (existsSync(migrationsOut) && !readdirSync(migrationsOut).length) rmSync(migrationsOut, { recursive: true, force: true });
259
+
260
+ // pb_secrets: the declaration vb_secrets/main.ts makes, in the shape `voidbase deploy` reads, and the values beside it
261
+ drop("pb_secrets");
262
+ if (m.secrets) {
263
+ put("pb_secrets/main.pb.js", generateSecretsDeclaration(m.secrets));
264
+ const values = join(m.root, m.extras.secretsDir!, "secrets.json");
265
+ if (existsSync(values)) { cpSync(values, join(m.root, OUT, "pb_secrets/secrets.json")); written.push(`${OUT}/pb_secrets/secrets.json`); }
266
+ }
267
+
268
+ return { written, removed };
269
+ }
@@ -0,0 +1,6 @@
1
+ // Node side of the Void adapter: the Vite plugin, the one-shot `adapt()` and the pieces they are made of.
2
+ // The runtime half (`mountVoidApp`) is a separate entry, "@voidbase-cloud/voidbase/adapter", because generated
3
+ // app code imports it and must stay free of node:fs so it bundles into a Worker.
4
+ export { adapt, voidbaseAdapter, type AdaptResult, type AdapterOptions } from "./plugin";
5
+ export { generateMainEntry, generateMigration, generateServerModule, splitSql, writeVoidbaseApp, type GenerateOptions, type WriteResult } from "./codegen";
6
+ export { exportedNames, routeUrl, scanVoidApp, type ScanOptions, type VoidManifest, type VoidMigration, type VoidModule, type VoidQueue, type VoidRoute } from "./scan";
@@ -0,0 +1,117 @@
1
+ // The Vite plugin a Void app adds next to voidPlugin(): it makes `vite build` produce a voidbase app.
2
+ //
3
+ // import { voidPlugin } from "void";
4
+ // import { voidbaseAdapter } from "@voidbase-cloud/voidbase/adapter/plugin";
5
+ // export default defineConfig({ plugins: [voidPlugin(), voidbaseAdapter()] });
6
+ //
7
+ // The whole voidbase app is generated under `.voidbase/`, in PocketBase's layout: the client build lands in
8
+ // `.voidbase/pb_public` (served at `/`), and the server code (routes/, middleware/, vb_hooks/, crons/, queues/)
9
+ // is bundled into `.voidbase/pb_hooks/`. The whole `.voidbase/` directory is build output: delete it and the next
10
+ // build makes it again.
11
+ // The project root stays a plain Void app. Void's own dist/ssr worker is left alone: voidbase composes the
12
+ // generated main.ts into its own Worker on deploy, so the app's server code is bundled there instead.
13
+ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs";
14
+ import { join, resolve } from "node:path";
15
+ import { bundleHookApp } from "./bundle";
16
+ import { generateHookWrapper, hasServerCode, writeVoidbaseApp, type GenerateOptions } from "./codegen";
17
+ import { scanVoidApp, type VoidManifest } from "./scan";
18
+
19
+ export interface AdapterOptions extends GenerateOptions {
20
+ /** where the built site goes inside the generated app; voidbase serves it at `/` */
21
+ publicDir?: string;
22
+ /** the client build to copy from; defaults to <outDir>/client, then dist/client */
23
+ clientDir?: string;
24
+ /** turn db/migrations/*.sql into pb_migrations/*.void.js (default true) */
25
+ migrations?: boolean;
26
+ /** log what was produced (default true) */
27
+ quiet?: boolean;
28
+ }
29
+
30
+ export interface AdaptResult { manifest: VoidManifest; written: string[]; copied: number; bundleBytes: number }
31
+
32
+ /** Runs the whole conversion once. Exported so `voidbase adapt` and the tests do not need Vite. */
33
+ export async function adapt(root: string, opts: AdapterOptions & { clientDir?: string } = {}): Promise<AdaptResult> {
34
+ const manifest = scanVoidApp({ root, dev: false });
35
+ const { written } = writeVoidbaseApp(manifest, { pkg: opts.pkg, migrations: opts.migrations });
36
+
37
+ // routes/, middleware/, vb_hooks/, crons/ and queues/ become one bundled hook: a hook cannot import from npm, and Void's
38
+ // handlers do (see src/adapter/bundle.ts)
39
+ let bundleBytes = 0;
40
+ if (hasServerCode(manifest)) {
41
+ const hooks = join(root, ".voidbase", "pb_hooks");
42
+ mkdirSync(hooks, { recursive: true });
43
+ const { code, bytes } = await bundleHookApp(join(root, ".voidbase", "void-entry.ts"), root);
44
+ writeFileSync(join(hooks, "void-app.js"), code);
45
+ writeFileSync(join(hooks, "void-app.pb.js"), generateHookWrapper());
46
+ written.push(".voidbase/pb_hooks/void-app.js", ".voidbase/pb_hooks/void-app.pb.js");
47
+ bundleBytes = bytes;
48
+ }
49
+
50
+ const publicDir = resolve(root, opts.publicDir ?? ".voidbase/pb_public");
51
+ const client = opts.clientDir ? resolve(root, opts.clientDir) : firstExisting([join(root, "dist", "client"), join(root, "public")]);
52
+ const copied = client ? syncPublic(client, publicDir) : 0;
53
+ return { manifest, written, copied, bundleBytes };
54
+ }
55
+
56
+ const firstExisting = (paths: string[]) => paths.find((p) => existsSync(p) && statSync(p).isDirectory());
57
+
58
+ /** Replaces pb_public with the build, keeping `_` (the admin panel) and dotfiles, and giving the asset layer a 404. */
59
+ function syncPublic(from: string, to: string): number {
60
+ mkdirSync(to, { recursive: true });
61
+ for (const entry of readdirSync(to)) {
62
+ if (entry === "_" || entry.startsWith(".")) continue;
63
+ rmSync(join(to, entry), { recursive: true, force: true });
64
+ }
65
+ let copied = 0;
66
+ for (const entry of readdirSync(from)) {
67
+ if (entry === "_") continue; // that path belongs to the admin panel
68
+ cpSync(join(from, entry), join(to, entry), { recursive: true });
69
+ copied++;
70
+ }
71
+ // Cloudflare answers an unmatched path with 404.html (void.json routing.notFound "404-page"); on Bun voidbase
72
+ // falls back to index.html. Copying one to the other keeps a deep link behaving the same on both.
73
+ const index = join(to, "index.html");
74
+ if (existsSync(index) && !existsSync(join(to, "404.html"))) cpSync(index, join(to, "404.html"));
75
+ return copied;
76
+ }
77
+
78
+ export function voidbaseAdapter(options: AdapterOptions = {}) {
79
+ let root = process.cwd();
80
+ let clientOut: string | undefined;
81
+ let hasClient = false;
82
+ const log = (msg: string) => { if (!options.quiet) console.log(`voidbase: ${msg}`); };
83
+ // Rolldown prints only a plugin error's first line, and the adapter's build errors say what to change on the
84
+ // lines after it. Print the whole thing before it is rethrown.
85
+ const report = async <T>(work: () => T | Promise<T>): Promise<T> => {
86
+ try { return await work(); } catch (err) { if (err instanceof Error && err.message.includes("\n")) console.error(err.message); throw err; }
87
+ };
88
+
89
+ return {
90
+ name: "voidbase-adapter",
91
+ // after voidPlugin, so the manifest sees whatever it generated into .void/
92
+ enforce: "post" as const,
93
+ configResolved(config: { root: string; build?: { outDir?: string }; environments?: Record<string, { build?: { outDir?: string } }> }) {
94
+ root = config.root ?? root;
95
+ const fromEnv = config.environments?.client?.build?.outDir;
96
+ hasClient = !!config.environments?.client;
97
+ clientOut = fromEnv ?? config.build?.outDir;
98
+ },
99
+ async buildStart() {
100
+ // keep the glue in step with the files while developing, so `voidbase serve --entry main.ts` sees new routes
101
+ const manifest = await report(() => scanVoidApp({ root, dev: process.env.NODE_ENV !== "production" }));
102
+ writeVoidbaseApp(manifest, { pkg: options.pkg, migrations: options.migrations });
103
+ },
104
+ // fires once per built environment; the work is idempotent and the client builds last, so report only then
105
+ async closeBundle(this: { environment?: { name?: string } }) {
106
+ const clientDir = options.clientDir ?? (clientOut && existsSync(resolve(root, clientOut)) ? clientOut : undefined);
107
+ const { manifest, copied, bundleBytes } = await report(() => adapt(root, { ...options, clientDir }));
108
+ const counts = `${manifest.routes.length} route(s), ${manifest.middleware.length} middleware, ${manifest.hooks.length} hook(s), ${manifest.crons.length} cron(s), ${manifest.queues.length} queue(s), ${manifest.migrations.length} migration(s)${manifest.secrets ? `, ${manifest.secrets.names.length} secret(s)` : ""}${bundleBytes ? ` -> pb_hooks/void-app.js (${Math.round(bundleBytes / 1024)} kB)` : ""}`;
109
+ if (!hasClient || this.environment?.name === "client") {
110
+ log(`${manifest.mode === "static" ? "static site" : counts}; ${copied} entr(ies) into ${options.publicDir ?? ".voidbase/pb_public"}`);
111
+ for (const u of manifest.unsupported) console.warn(`voidbase: ${u.what} is not carried over — ${u.why}`);
112
+ for (const c of manifest.collisions) console.warn(`voidbase: ${c} is served by voidbase itself, so the app route never runs — move it off that path`);
113
+ }
114
+ writeFileSync(join(root, ".voidbase", "manifest.json"), JSON.stringify({ ...manifest, root: undefined }, null, 2) + "\n");
115
+ },
116
+ };
117
+ }