@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
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// The app's configuration, declared once with Void's validators and stored in each deploy's environment
|
|
2
|
+
// (twelve-factor III: config in the environment, declared in code, never grouped by environment):
|
|
3
|
+
//
|
|
4
|
+
// // pb_secrets/main.ts (a Void app: vb_secrets/main.ts)
|
|
5
|
+
// import { defineSecrets, string, number, url, describe } from "@voidbase-cloud/voidbase/secrets";
|
|
6
|
+
//
|
|
7
|
+
// export default defineSecrets({
|
|
8
|
+
// SMTP_PASSWORD: string().secret(),
|
|
9
|
+
// ADMIN_EMAILS: describe(string().default(""), "who may open the admin pages"),
|
|
10
|
+
// MAX_INSTANCES: number().default(5),
|
|
11
|
+
// PUBLIC_API_URL: url().optional().public(),
|
|
12
|
+
// });
|
|
13
|
+
//
|
|
14
|
+
// Every key has an access tier, which decides where its value lives and who can read it:
|
|
15
|
+
//
|
|
16
|
+
// .secret() the Worker's encrypted secrets; read by hooks and routes; never listed, never in a build
|
|
17
|
+
// (plain) server configuration: the Worker's plain vars; read by hooks and routes; never in a client build
|
|
18
|
+
// .public() the Worker's plain vars *and* the client build (`import.meta.env.KEY`): what the browser may know
|
|
19
|
+
//
|
|
20
|
+
// The validators are Void's own (`string()`, `number()`, `boolean()`, `url()`, `email()`, `oneOf()`, `json()`,
|
|
21
|
+
// each with `.optional()`, `.default()`, `.secret()`, `.public()`), the same ones a Void project's env.ts uses, so
|
|
22
|
+
// one vocabulary serves both. Any Standard Schema validator works too (wrap it in `secret()` / `pub()` to tier it).
|
|
23
|
+
//
|
|
24
|
+
// Values come from the deploy's environment: locally `secrets.json` (git-ignored) beside the declaration, then the
|
|
25
|
+
// shell; on Cloudflare the Worker's secrets and vars. `voidbase serve`, `voidbase deploy` and the adapter's build
|
|
26
|
+
// parse them through the validators, so a default is filled in, a number is a number, and a bad or missing value
|
|
27
|
+
// stops the process with the key's name, never its value.
|
|
28
|
+
//
|
|
29
|
+
// This module is imported by the app's own code too (`await definition.read(c.env)` for typed access), so it must
|
|
30
|
+
// stay small: Void's validators and nothing else.
|
|
31
|
+
import { boolean, email, json, number, oneOf, string, url } from "void/env";
|
|
32
|
+
|
|
33
|
+
export { boolean, email, json, number, oneOf, string, url };
|
|
34
|
+
|
|
35
|
+
// Standard Schema V1, inlined as the spec allows
|
|
36
|
+
export interface StandardSchema<Output = unknown> {
|
|
37
|
+
readonly "~standard": {
|
|
38
|
+
readonly version: 1;
|
|
39
|
+
readonly vendor: string;
|
|
40
|
+
readonly validate: (value: unknown) => StandardResult<Output> | Promise<StandardResult<Output>>;
|
|
41
|
+
readonly types?: { readonly input: unknown; readonly output: Output } | undefined;
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
type StandardResult<Output> = { readonly value: Output; readonly issues?: undefined } | { readonly issues: ReadonlyArray<{ readonly message: string }> };
|
|
45
|
+
export type OutputOf<S> = S extends StandardSchema<infer O> ? O : never;
|
|
46
|
+
|
|
47
|
+
export type Access = "secret" | "server" | "public";
|
|
48
|
+
|
|
49
|
+
export interface Entry<S extends StandardSchema = StandardSchema> {
|
|
50
|
+
schema: S;
|
|
51
|
+
/** the tier; without one, Void's `.secret()` / `.public()` marker on the validator decides, else `server` */
|
|
52
|
+
access?: Access;
|
|
53
|
+
description?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Void's marker, set by `.secret()` and `.public()` on its validators (a Symbol.for, so the same across copies). */
|
|
57
|
+
const VOID_MARKER = Symbol.for("void.env.secretOverride");
|
|
58
|
+
const markerOf = (schema: unknown): Access | undefined => {
|
|
59
|
+
const m = schema && typeof schema === "object" ? (schema as Record<symbol, unknown>)[VOID_MARKER] : undefined;
|
|
60
|
+
return m === "secret" ? "secret" : m === "public" ? "public" : undefined;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const ENTRY = Symbol.for("voidbase.secrets.entry");
|
|
64
|
+
type Marked<S extends StandardSchema> = Entry<S> & { [ENTRY]: true };
|
|
65
|
+
const entry = <S extends StandardSchema>(e: Entry<S>): Marked<S> => ({ ...e, [ENTRY]: true });
|
|
66
|
+
const isEntry = (v: unknown): v is Marked<StandardSchema> => !!v && typeof v === "object" && ENTRY in (v as object);
|
|
67
|
+
|
|
68
|
+
/** Attaches a description to a validator: shown by `voidbase secrets` and in the deploy's messages. */
|
|
69
|
+
export function describe<S extends StandardSchema>(schema: S, description: string): Marked<S> { return entry({ schema, description }); }
|
|
70
|
+
/** Tiers any Standard Schema validator as a secret (Void's own validators can say `.secret()` instead). */
|
|
71
|
+
export function secret<S extends StandardSchema>(schema: S, description?: string): Marked<S> { return entry({ schema, access: "secret", description }); }
|
|
72
|
+
/** Tiers any Standard Schema validator as server configuration: a plain Worker var, never in a client build. */
|
|
73
|
+
export function server<S extends StandardSchema>(schema: S, description?: string): Marked<S> { return entry({ schema, access: "server", description }); }
|
|
74
|
+
/** Tiers any Standard Schema validator as public: a Worker var the browser may also know (`.public()` on Void's). */
|
|
75
|
+
export function pub<S extends StandardSchema>(schema: S, description?: string): Marked<S> { return entry({ schema, access: "public", description }); }
|
|
76
|
+
|
|
77
|
+
export type Spec = Record<string, StandardSchema | Entry>;
|
|
78
|
+
type SchemaOf<E> = E extends Entry<infer S> ? S : E extends StandardSchema ? E : never;
|
|
79
|
+
/** The typed configuration a definition parses to. */
|
|
80
|
+
export type Values<T extends Spec> = { [K in keyof T]: OutputOf<SchemaOf<T[K]>> };
|
|
81
|
+
export type ValuesOf<D> = D extends Definition<infer T> ? Values<T> : never;
|
|
82
|
+
|
|
83
|
+
export interface KeyInfo {
|
|
84
|
+
name: string;
|
|
85
|
+
access: Access;
|
|
86
|
+
description?: string;
|
|
87
|
+
/** the validator accepts no value at all: it has a default or is optional */
|
|
88
|
+
optional: boolean;
|
|
89
|
+
/** the value the validator fills in when none is given, as it will be stored (a string), or undefined */
|
|
90
|
+
fallback?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** What parsing a set of raw values produced. Values never appear in `missing` or `invalid`. */
|
|
94
|
+
export interface Evaluation<T extends Spec> {
|
|
95
|
+
/** every key that parsed, with its typed value */
|
|
96
|
+
values: Partial<Values<T>>;
|
|
97
|
+
/** every key that parsed, as the string the environment stores (numbers and booleans stringified, objects as JSON) */
|
|
98
|
+
stored: Record<string, string>;
|
|
99
|
+
/** keys with no value and no default */
|
|
100
|
+
missing: string[];
|
|
101
|
+
/** keys whose value the validator refused: name and why */
|
|
102
|
+
invalid: { name: string; message: string }[];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const NAME = /^[A-Z][A-Z0-9_]*$/;
|
|
106
|
+
/** where raw values come from: an environment object, or a lookup (`(name) => $os.getenv(name)` in a hook or route) */
|
|
107
|
+
export type Source = Record<string, unknown> | ((name: string) => unknown);
|
|
108
|
+
|
|
109
|
+
/** A configuration value as the environment stores it: environments hold strings. */
|
|
110
|
+
export function toStored(v: unknown): string | undefined {
|
|
111
|
+
if (v === undefined || v === null) return undefined;
|
|
112
|
+
if (typeof v === "string") return v;
|
|
113
|
+
if (typeof v === "number" || typeof v === "boolean" || typeof v === "bigint") return String(v);
|
|
114
|
+
if (v instanceof Date) return v.toISOString();
|
|
115
|
+
return JSON.stringify(v);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const validate = async <O>(schema: StandardSchema<O>, value: unknown): Promise<StandardResult<O>> => schema["~standard"].validate(value);
|
|
119
|
+
|
|
120
|
+
export class Definition<T extends Spec> {
|
|
121
|
+
readonly entries: { [K in keyof T]: Required<Pick<Entry<SchemaOf<T[K]>>, "schema" | "access">> & Pick<Entry, "description"> };
|
|
122
|
+
constructor(spec: T) {
|
|
123
|
+
const entries = {} as Definition<T>["entries"];
|
|
124
|
+
for (const [name, v] of Object.entries(spec)) {
|
|
125
|
+
if (!NAME.test(name)) throw new Error(`voidbase: "${name}" is not a configuration name (UPPER_CASE, letters, digits and underscores, like an environment variable)`);
|
|
126
|
+
const e: Entry = isEntry(v) ? { schema: v.schema, access: v.access, description: v.description } : { schema: v as StandardSchema };
|
|
127
|
+
if (!e.schema || typeof e.schema !== "object" || !("~standard" in e.schema)) throw new Error(`voidbase: ${name} needs a validator (string(), number(), url(), ... from @voidbase-cloud/voidbase/secrets, or any Standard Schema)`);
|
|
128
|
+
(entries as Record<string, unknown>)[name] = { schema: e.schema, access: e.access ?? markerOf(e.schema) ?? "server", description: e.description };
|
|
129
|
+
}
|
|
130
|
+
this.entries = entries;
|
|
131
|
+
}
|
|
132
|
+
/** the names, in declaration order */
|
|
133
|
+
get names(): (keyof T & string)[] { return Object.keys(this.entries) as (keyof T & string)[]; }
|
|
134
|
+
/** the names of one tier, or of several */
|
|
135
|
+
of(...access: Access[]): (keyof T & string)[] { return this.names.filter((n) => access.includes(this.entries[n].access)); }
|
|
136
|
+
/** what each key is, without any value */
|
|
137
|
+
async info(): Promise<KeyInfo[]> {
|
|
138
|
+
const out: KeyInfo[] = [];
|
|
139
|
+
for (const name of this.names) {
|
|
140
|
+
const e = this.entries[name];
|
|
141
|
+
const empty = await validate(e.schema, undefined);
|
|
142
|
+
const ok = !empty.issues;
|
|
143
|
+
out.push({ name, access: e.access, description: e.description, optional: ok, fallback: ok ? toStored((empty as { value: unknown }).value) : undefined });
|
|
144
|
+
}
|
|
145
|
+
return out;
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Parses raw values (an environment: strings, or nothing) through the validators. Nothing throws: the result says
|
|
149
|
+
* which keys are missing and which were refused, by name, so a caller can stop with a list instead of one error.
|
|
150
|
+
* `only` restricts the parse to some tiers (a client build reads `public` and nothing else).
|
|
151
|
+
*/
|
|
152
|
+
async evaluate(raw: Source, only?: Access[]): Promise<Evaluation<T>> {
|
|
153
|
+
const values: Record<string, unknown> = {}; const stored: Record<string, string> = {}; const missing: string[] = []; const invalid: { name: string; message: string }[] = [];
|
|
154
|
+
const get = typeof raw === "function" ? raw : (n: string) => raw[n];
|
|
155
|
+
for (const name of only ? this.of(...only) : this.names) {
|
|
156
|
+
const e = this.entries[name];
|
|
157
|
+
const got = get(name); const input = got === "" ? undefined : got;
|
|
158
|
+
const r = await validate(e.schema, input);
|
|
159
|
+
if (r.issues) {
|
|
160
|
+
if (input === undefined) { missing.push(name); continue; }
|
|
161
|
+
// a validator may quote what it refused (Void's url() does): the report never carries the value
|
|
162
|
+
const shown = typeof input === "string" ? input : String(input);
|
|
163
|
+
invalid.push({ name, message: r.issues.map((i) => (shown ? i.message.split(shown).join("<value>") : i.message)).join("; ") });
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if (r.value === undefined) continue; // optional and absent: the app reads undefined, as declared
|
|
167
|
+
values[name] = r.value; const s = toStored(r.value); if (s !== undefined) stored[name] = s;
|
|
168
|
+
}
|
|
169
|
+
return { values: values as Partial<Values<T>>, stored, missing, invalid };
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Typed access from the app's own code: `await definition.read((n) => pb.$os.getenv(n))` in a route or hook works
|
|
173
|
+
* on both runtimes; `read(c.env)` on a Worker, `read(process.env)` on Bun.
|
|
174
|
+
* Throws with the names of the keys that are missing or refused, never their values.
|
|
175
|
+
*/
|
|
176
|
+
async read(raw: Source, only?: Access[]): Promise<Values<T>> {
|
|
177
|
+
const r = await this.evaluate(raw, only);
|
|
178
|
+
const problems = [...r.missing.map((n) => `${n}: missing`), ...r.invalid.map((i) => `${i.name}: ${i.message}`)];
|
|
179
|
+
if (problems.length) throw new Error(`voidbase: configuration: ${problems.join(", ")}`);
|
|
180
|
+
return r.values as Values<T>;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Declares the app's configuration. The result is the declaration `voidbase serve`, `voidbase deploy` and the
|
|
186
|
+
* adapter read, and the typed reader the app's code uses:
|
|
187
|
+
*
|
|
188
|
+
* export default defineSecrets({ SMTP_PASSWORD: string().secret(), MAX: number().default(5) });
|
|
189
|
+
* // elsewhere: const { MAX } = await definition.read(c.env); MAX is a number
|
|
190
|
+
*/
|
|
191
|
+
export function defineSecrets<const T extends Spec>(spec: T): Definition<T> {
|
|
192
|
+
return new Definition(spec);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export const isDefinition = (v: unknown): v is Definition<Spec> => v instanceof Definition || (!!v && typeof v === "object" && typeof (v as Definition<Spec>).evaluate === "function" && !!(v as Definition<Spec>).entries);
|
package/src/node/assets.ts
CHANGED
|
@@ -2,10 +2,16 @@
|
|
|
2
2
|
import { existsSync, statSync } from "node:fs";
|
|
3
3
|
import { join, normalize } from "node:path";
|
|
4
4
|
export function assetsFetcher(opts: { panelDir: string; publicDir?: string }) {
|
|
5
|
+
// Cloudflare's asset layer resolves an extensionless path against `<path>.html` and `<path>/index.html`
|
|
6
|
+
// (html_handling "auto-trailing-slash"); a static site generator writes one shape or the other, so the Bun
|
|
7
|
+
// runtime tries both and a deep link behaves the same on either runtime.
|
|
8
|
+
const isFile = (p: string) => existsSync(p) && statSync(p).isFile();
|
|
5
9
|
const file = (root: string, rel: string): string | null => {
|
|
6
10
|
const p = normalize(join(root, rel));
|
|
7
11
|
if (!p.startsWith(normalize(root))) return null;
|
|
8
|
-
if (
|
|
12
|
+
if (isFile(p)) return p;
|
|
13
|
+
const html = p.replace(/\/$/, "") + ".html";
|
|
14
|
+
if (html !== ".html" && isFile(html)) return html;
|
|
9
15
|
const index = join(p, "index.html");
|
|
10
16
|
return existsSync(index) ? index : null;
|
|
11
17
|
};
|
package/src/node/cloud-init.ts
CHANGED
|
@@ -7,6 +7,20 @@ const ROOT = resolve(import.meta.dir, "../..");
|
|
|
7
7
|
|
|
8
8
|
// mode "package": a visible project importing the voidbase package (voidbase cloud init).
|
|
9
9
|
// mode "internal": a project inside this package at .cloud/<slug>, importing ../../src etc. (voidbase deploy).
|
|
10
|
+
export interface RedirectEntry { source: string; host?: string; path: string; to: string; status: number; line: number }
|
|
11
|
+
// Netlify/Pages-style `_redirects`: `source destination [status]`, `#` comments. A source may carry a host
|
|
12
|
+
// (`https://api.example.com/`), which scopes the rule to that hostname. 3xx rules only (default 302).
|
|
13
|
+
export function parseRedirects(text: string): RedirectEntry[] {
|
|
14
|
+
const out: RedirectEntry[] = [];
|
|
15
|
+
text.split("\n").forEach((raw, i) => {
|
|
16
|
+
const line = raw.replace(/#.*$/, "").trim(); if (!line) return;
|
|
17
|
+
const [source, to, statusRaw] = line.split(/\s+/); if (!source || !to) return;
|
|
18
|
+
const status = Number((statusRaw ?? "302").replace(/!$/, "")); if (![301, 302, 303, 307, 308].includes(status)) return;
|
|
19
|
+
const m = source.match(/^https?:\/\/([^/]+)(\/.*)?$/);
|
|
20
|
+
out.push({ source, host: m ? m[1]!.toLowerCase() : undefined, path: m ? m[2] || "/" : source, to, status, line: i + 1 });
|
|
21
|
+
});
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
10
24
|
export function writeCloudProject(out: string, mode: "package" | "internal" = "package", extra: { hooksDir?: string; migrationsDir?: string; entry?: string; queue?: string | false; hub?: boolean } = {}): { files: number; out: string } {
|
|
11
25
|
const parentPkg = existsSync("package.json") ? (JSON.parse(readFileSync("package.json", "utf8")) as { dependencies?: Record<string, string> }) : {};
|
|
12
26
|
const spec = parentPkg.dependencies?.["@voidbase-cloud/voidbase"] ?? parentPkg.dependencies?.voidbase ?? "^0.1.0";
|
package/src/node/deploy-cf.ts
CHANGED
|
@@ -5,9 +5,10 @@
|
|
|
5
5
|
// which builds, applies the D1 migrations and uploads the Worker.
|
|
6
6
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
7
7
|
import { resolve } from "node:path";
|
|
8
|
-
import { writeCloudProject } from "./cloud-init";
|
|
8
|
+
import { parseRedirects, writeCloudProject, type RedirectEntry } from "./cloud-init";
|
|
9
9
|
import { loadEnv } from "./serve";
|
|
10
|
-
import {
|
|
10
|
+
import { loadSecrets, SECRETS_DIR, workerSecretNames, type LoadedSecrets } from "./secrets";
|
|
11
|
+
import { CfApi, attachCustomDomain, ensureD1, ensureQueue, ensureR2, findZone, rateLimitNamespace, resolveAccount, workersSubdomain } from "../cloud/rest";
|
|
11
12
|
|
|
12
13
|
const API = (process.env.CLOUDFLARE_API_BASE ?? "https://api.cloudflare.com/client/v4").replace(/\/$/, "");
|
|
13
14
|
export const TOKEN_ENV = "VOIDBASE_DEPLOY_CF_API_KEY";
|
|
@@ -73,14 +74,28 @@ export function loadEnvFiles(files = [".env", ".env.local", "../.env", "../.env.
|
|
|
73
74
|
return loaded;
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
|
|
77
|
+
/** The environment, the token, the account and the worker name a deploy (or `voidbase secrets`) targets. */
|
|
78
|
+
export async function deployTarget(opts: Pick<DeployOptions, "name" | "account" | "log"> = {}): Promise<{ api: CfApi; token: string; account: { id: string; name: string }; name: string; secretsDir: string; secrets: LoadedSecrets }> {
|
|
77
79
|
const log = opts.log ?? ((l: string) => console.log(l));
|
|
80
|
+
// pb_secrets/ first: the declared names, and on a dev machine their values, which count as environment from here
|
|
81
|
+
// on. The shell outranks secrets.json, and secrets.json outranks the .env files, so a dev placeholder in .env
|
|
82
|
+
// (VOIDBASE_SUPERUSER_PASSWORD=changeme123) never shadows the real value kept beside the declaration.
|
|
83
|
+
const secretsDir = resolve(process.env.VOIDBASE_SECRETS_DIR || SECRETS_DIR);
|
|
84
|
+
const secrets = await loadSecrets(secretsDir);
|
|
85
|
+
if (secrets.invalid.length) throw new Error(`${secretsDir}: ${secrets.invalid.map((i) => `${i.name}: ${i.message}`).join(", ")}`);
|
|
78
86
|
loadEnv(); const fromFiles = loadEnvFiles(); if (fromFiles.length) log(`from .env: ${fromFiles.join(", ")}`);
|
|
87
|
+
if (secrets.state.definition) { const d = secrets.state.definition; log(`${secretsDir}: ${d.names.length} declared (${d.of("secret").length} secret, ${d.of("server").length} server, ${d.of("public").length} public), ${secrets.state.provided.length} valued here${secrets.undeclared.length ? `; in secrets.json but not declared (not deployed): ${secrets.undeclared.join(", ")}` : ""}`); }
|
|
79
88
|
const token = process.env[TOKEN_ENV] || process.env.CLOUDFLARE_API_TOKEN || ""; // empty means unset
|
|
80
89
|
if (!token) { log(`${TOKEN_ENV} is not set.\n\n${tokenHelp()}`); throw new Error(`${TOKEN_ENV} missing`); }
|
|
81
90
|
const name = slug(opts.name || process.env.VOIDBASE_DEPLOY_NAME || projectName());
|
|
82
91
|
const api = new CfApi(token, API);
|
|
83
92
|
const account = await resolveAccount(api, opts.account || process.env.VOIDBASE_DEPLOY_CF_ACCOUNT_ID || undefined).catch((e: Error) => { throw new Error(`${e.message} (is it ${TOKEN_ENV} with Account Settings read?)`); });
|
|
93
|
+
return { api, token, account, name, secretsDir, secrets };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export async function deployToCloudflare(opts: DeployOptions = {}): Promise<{ name: string; account: string; url: string | null; wranglerConfig: string; project: string }> {
|
|
97
|
+
const log = opts.log ?? ((l: string) => console.log(l));
|
|
98
|
+
const { api, token, account, name, secretsDir, secrets: pbSecrets } = await deployTarget(opts);
|
|
84
99
|
log(`account ${account.name} (${account.id}), worker "${name}"`);
|
|
85
100
|
const db = await ensureD1(api, account.id, `${name}-db`); log(`D1 ${name}-db ${db.created ? "created" : "exists"} (${db.uuid})`);
|
|
86
101
|
const bucket = await ensureR2(api, account.id, `${name}-storage`); log(`R2 ${name}-storage ${bucket.created ? "created" : "exists"}`);
|
|
@@ -93,8 +108,19 @@ export async function deployToCloudflare(opts: DeployOptions = {}): Promise<{ na
|
|
|
93
108
|
// Workers Free allows 5 cron triggers per account; without the trigger PocketBase's maintenance runs lazily in requests
|
|
94
109
|
const cron = opts.cron ?? !off(process.env.VOIDBASE_DEPLOY_CRON);
|
|
95
110
|
// a custom domain on a zone of the account (wrangler attaches it: DNS record + certificate); workers.dev is then off
|
|
96
|
-
|
|
97
|
-
|
|
111
|
+
// several hostnames may be listed (comma separated); the first is the Worker's URL, all are attached
|
|
112
|
+
const domains = String(opts.domain || process.env.VOIDBASE_DEPLOY_DOMAIN || "").split(",").map((d) => d.trim().replace(/^https?:\/\//, "").replace(/\/.*$/, "").toLowerCase()).filter(Boolean);
|
|
113
|
+
for (const d of domains) if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(d)) throw new Error(`invalid custom domain "${d}"`);
|
|
114
|
+
const domain = domains[0] ?? "";
|
|
115
|
+
// the static site next to the API: --public-dir, VOIDBASE_DEPLOY_PUBLIC_DIR, or ./pb_public when it exists (PocketBase's default)
|
|
116
|
+
const publicDir = opts.publicDir || process.env.VOIDBASE_DEPLOY_PUBLIC_DIR || (existsSync(resolve("pb_public")) ? "pb_public" : undefined);
|
|
117
|
+
if (publicDir && !existsSync(resolve(publicDir, "index.html"))) throw new Error(`public dir ${resolve(publicDir)} has no index.html (build the site first)`);
|
|
118
|
+
// <public dir>/_redirects, Netlify/Pages syntax. Path-only lines go to the assets as Cloudflare's own _redirects (it
|
|
119
|
+
// only accepts relative sources); host-scoped lines (`https://api.example.com/ /_/ 302`) become zone Redirect Rules
|
|
120
|
+
// after the upload, which is how one Worker behind several custom domains answers differently per hostname.
|
|
121
|
+
const redirects = publicDir && existsSync(resolve(publicDir, "_redirects")) ? parseRedirects(readFileSync(resolve(publicDir, "_redirects"), "utf8")) : [];
|
|
122
|
+
const hostRedirects = redirects.filter((r) => r.host), pathRedirects = redirects.filter((r) => !r.host);
|
|
123
|
+
if (redirects.length) log(`redirects (${resolve(publicDir!, "_redirects")}): ${redirects.map((r) => `${r.source} -> ${r.to} (${r.status})`).join(", ")}${hostRedirects.length ? `; the ${hostRedirects.length} host-scoped rule(s) become zone Redirect Rules after the upload` : ""}`);
|
|
98
124
|
let queue: string | false = false;
|
|
99
125
|
if (wantQueue) {
|
|
100
126
|
const q = await ensureQueue(api, account.id, `${name}-jobs`);
|
|
@@ -132,6 +158,16 @@ export async function deployToCloudflare(opts: DeployOptions = {}): Promise<{ na
|
|
|
132
158
|
const extraVars = listed("VOIDBASE_DEPLOY_VARS"), extraSecrets = listed("VOIDBASE_DEPLOY_SECRETS");
|
|
133
159
|
const baked: Record<string, string> = { VOIDBASE_WORKER_NAME: name, VOIDBASE_ACCOUNT_ID: account.id };
|
|
134
160
|
for (const k of ["AUDITLOG", ...extraVars]) if (process.env[k]) baked[k] = process.env[k]!;
|
|
161
|
+
// the declared server and public values, parsed (defaults filled in): a var is the code's to set on every deploy
|
|
162
|
+
const definition = pbSecrets.state.definition;
|
|
163
|
+
const plainKeys = definition ? definition.of("server", "public") : [];
|
|
164
|
+
for (const k of plainKeys) { const v = pbSecrets.evaluation?.stored[k]; if (v !== undefined) baked[k] = v; }
|
|
165
|
+
const missingVars = pbSecrets.missing.filter((k) => plainKeys.includes(k));
|
|
166
|
+
if (missingVars.length) {
|
|
167
|
+
const msg = `${missingVars.length} declared value(s) have no value in ${secretsDir}/secrets.json or the environment and no default: ${missingVars.join(", ")}`;
|
|
168
|
+
if (opts.dryRun) log(`vars: ${msg}`); else throw new Error(msg);
|
|
169
|
+
}
|
|
170
|
+
if (plainKeys.length) log(`vars: ${plainKeys.filter((k) => baked[k] !== undefined).join(", ") || "none"}${definition!.of("public").length ? ` (public: ${definition!.of("public").join(", ")})` : ""}`);
|
|
135
171
|
writeFileSync(`${cloud}/.env`, Object.entries(baked).map(([k, v]) => `${k}=${v}\n`).join(""));
|
|
136
172
|
log(`project: ${cloud}`);
|
|
137
173
|
|
|
@@ -143,14 +179,43 @@ export async function deployToCloudflare(opts: DeployOptions = {}): Promise<{ na
|
|
|
143
179
|
const saved = existsSync(credFile) ? (JSON.parse(readFileSync(credFile, "utf8")) as { email: string; password: string }) : null;
|
|
144
180
|
const placeholder = !password || password === "changeme123"; // the local dev default never goes live
|
|
145
181
|
if (placeholder && saved && (!email || email === saved.email)) { email = saved.email; password = saved.password; }
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
182
|
+
// what the Worker already holds: a checkout with no credentials of its own (CI) must not replace the superuser
|
|
183
|
+
// the Worker has with a generated one that only this checkout would know
|
|
184
|
+
const onWorker = await workerSecretNames(api, account.id, name);
|
|
185
|
+
const keepSuperuser = placeholder && !saved && onWorker.includes("VOIDBASE_SUPERUSER_EMAIL") && onWorker.includes("VOIDBASE_SUPERUSER_PASSWORD");
|
|
186
|
+
if (keepSuperuser) log("superuser: no credentials in this checkout, the Worker keeps the ones it has");
|
|
187
|
+
else {
|
|
188
|
+
if (!email) email = "admin@example.com";
|
|
189
|
+
if (!password || password === "changeme123") { password = randomPassword(); log(`generated a superuser password for ${email} (saved in ${credFile}; change it after the first login)`); }
|
|
190
|
+
writeFileSync(credFile, JSON.stringify({ email, password }, null, 2) + "\n", { mode: 0o600 });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// the Worker's secrets: the superuser, VOIDBASE_DEPLOY_SECRETS=X,Y from the environment, and the declared
|
|
194
|
+
// pb_secrets/ names the Worker does not have yet. A deploy ships code; a value the Worker already holds is
|
|
195
|
+
// replaced only by `voidbase secrets push`, so a checkout whose secrets.json carries dev values (another OAuth
|
|
196
|
+
// client, the placeholder password) cannot overwrite production by deploying. A declared name with no value
|
|
197
|
+
// here must already be on the Worker.
|
|
198
|
+
const declared = definition ? definition.of("secret") : [];
|
|
199
|
+
const secretMap = new Map<string, string>(keepSuperuser ? [] : [["VOIDBASE_SUPERUSER_EMAIL", email], ["VOIDBASE_SUPERUSER_PASSWORD", password]]);
|
|
200
|
+
for (const k of extraSecrets) if (process.env[k]) secretMap.set(k, process.env[k]!);
|
|
201
|
+
const kept: string[] = [];
|
|
202
|
+
for (const k of declared) {
|
|
203
|
+
const v = pbSecrets.evaluation?.stored[k]; if (v === undefined) continue;
|
|
204
|
+
if (onWorker.includes(k)) { kept.push(k); continue; }
|
|
205
|
+
if (k === "VOIDBASE_SUPERUSER_PASSWORD" && v === "changeme123") { log("secrets: VOIDBASE_SUPERUSER_PASSWORD in secrets.json is the dev placeholder, not stored"); continue; }
|
|
206
|
+
secretMap.set(k, v);
|
|
207
|
+
}
|
|
208
|
+
const missingSecrets = declared.filter((k) => !secretMap.has(k) && !onWorker.includes(k));
|
|
209
|
+
if (missingSecrets.length) {
|
|
210
|
+
const msg = `${missingSecrets.length} declared secret(s) have no value in ${secretsDir}/secrets.json and are not on the Worker "${name}" yet: ${missingSecrets.join(", ")}. Push them once from a machine that has them: voidbase secrets push --name ${name}`;
|
|
211
|
+
if (opts.dryRun) log(`secrets: ${msg}`); else throw new Error(msg);
|
|
212
|
+
} else if (declared.length) log(`secrets: ${declared.length} declared; ${declared.filter((k) => secretMap.has(k)).length} stored from here, ${kept.length} kept as the Worker has them (voidbase secrets push replaces)`);
|
|
213
|
+
const secrets = [...secretMap.entries()];
|
|
149
214
|
|
|
150
215
|
const url = domain ? `https://${domain}` : await workersSubdomain(api, account.id).then((s) => (s ? `https://${name}.${s}.workers.dev` : null));
|
|
151
|
-
if (domain) log(`custom domain ${
|
|
216
|
+
if (domain) log(`custom domain${domains.length > 1 ? "s" : ""} ${domains.join(", ")} (workers.dev off): attached through the Workers Custom Domains API after the upload (Cloudflare adds the DNS record and certificate)`);
|
|
152
217
|
log(`bindings: D1, R2${hub ? ", realtime hub (Durable Object)" : ""}${queue ? ", Queue" : ""}${rateLimit ? `, rate limit ceiling ${rateLimit.limit}/${rateLimit.period}s per IP` : ""}${analytics ? ", Analytics Engine (needs Analytics Engine enabled once for the account: https://dash.cloudflare.com/" + account.id + "/workers/analytics-engine)" : ""}`);
|
|
153
|
-
if (opts.dryRun) { log(`dry run: would sync the panel${
|
|
218
|
+
if (opts.dryRun) { log(`dry run: would sync the panel${publicDir ? ` and ${publicDir}` : ""} into ${cloud}/public, put ${secrets.length} secrets (${secrets.map(([k]) => k).join(", ")}) and run void deploy --backend cloudflare (${url ?? "url unknown"})`); return { name, account: account.id, url, wranglerConfig, project: cloud }; }
|
|
154
219
|
|
|
155
220
|
// the toolchain comes with the voidbase package (void, and wrangler through void)
|
|
156
221
|
const voidDir = resolve(Bun.resolveSync("void/package.json", PKG), "..");
|
|
@@ -158,22 +223,65 @@ export async function deployToCloudflare(opts: DeployOptions = {}): Promise<{ na
|
|
|
158
223
|
// values also exported in the shell are stripped from baked vars by the Cloudflare backend, so keep the vars file clean instead
|
|
159
224
|
// the generated project has no node_modules of its own: `void deploy` shells out to `vite build`, so the package's toolchain goes on PATH
|
|
160
225
|
const binDirs = [resolve(PKG, "node_modules/.bin"), resolve(voidDir, "..", ".bin")].filter((d, i, a) => a.indexOf(d) === i);
|
|
161
|
-
const env: Record<string, string | undefined> = { ...process.env, PATH: `${binDirs.join(":")}:${process.env.PATH ?? ""}`, CLOUDFLARE_API_TOKEN: token, CLOUDFLARE_ACCOUNT_ID: account.id, VOIDBASE_SUPERUSER_EMAIL: email, VOIDBASE_SUPERUSER_PASSWORD: password };
|
|
226
|
+
const env: Record<string, string | undefined> = { ...process.env, PATH: `${binDirs.join(":")}:${process.env.PATH ?? ""}`, CLOUDFLARE_API_TOKEN: token, CLOUDFLARE_ACCOUNT_ID: account.id, ...(keepSuperuser ? {} : { VOIDBASE_SUPERUSER_EMAIL: email, VOIDBASE_SUPERUSER_PASSWORD: password }) };
|
|
162
227
|
for (const k of Object.keys(baked)) delete env[k];
|
|
163
228
|
const sh = async (cmd: string[], input?: string) => { const p = Bun.spawn(cmd, { cwd: cloud, env: env as Record<string, string>, stdin: input === undefined ? "inherit" : new TextEncoder().encode(input), stdout: "inherit", stderr: "inherit" }); const code = await p.exited; if (code !== 0) throw new Error(`${cmd.join(" ")} exited with ${code}`); };
|
|
164
229
|
mkdirSync(`${cloud}/public`, { recursive: true });
|
|
165
230
|
await sh(["bun", resolve(PKG, "scripts/sync-panel.ts"), "--dest", `${cloud}/public/_`]);
|
|
166
|
-
if (
|
|
167
|
-
|
|
231
|
+
if (publicDir) {
|
|
232
|
+
env.VOIDBASE_APP_DIR = resolve(publicDir); await sh(["bun", resolve(PKG, "scripts/sync-app.ts"), "--dest", `${cloud}/public`], undefined); log(`static site ${resolve(publicDir)} served at / (the panel stays at /_/)`);
|
|
233
|
+
if (pathRedirects.length) writeFileSync(`${cloud}/public/_redirects`, pathRedirects.map((r) => `${r.path} ${r.to} ${r.status}`).join("\n") + "\n");
|
|
234
|
+
}
|
|
235
|
+
if (secrets.length) log(`secrets: storing ${secrets.map(([k]) => k).join(", ")} on the Worker`);
|
|
168
236
|
for (const [k, v] of secrets) await sh(["bun", wrangler, "secret", "put", k, "--name", name], v + "\n");
|
|
169
237
|
await sh([voidBin, "deploy", "--backend", "cloudflare"]);
|
|
170
|
-
|
|
171
|
-
const d = await attachCustomDomain(api, account.id, { hostname:
|
|
238
|
+
for (const host of domains) {
|
|
239
|
+
const d = await attachCustomDomain(api, account.id, { hostname: host, service: name });
|
|
172
240
|
log(`custom domain ${d.hostname} ${d.created ? "attached" : "already attached"} (zone ${d.zone_id}); the certificate can take a minute`);
|
|
173
241
|
}
|
|
242
|
+
if (hostRedirects.length) await applyZoneRedirects(api, account.id, name, hostRedirects, log);
|
|
174
243
|
if (url) {
|
|
175
244
|
const ok = await fetch(`${url}/api/health`).then((r) => r.status).catch(() => 0);
|
|
176
|
-
log(`\nlive: ${url} (health ${ok || "not reachable yet"})\n├─ REST API: ${url}/api/\n└─ Dashboard: ${url}/_/ sign in as ${email} (password in ${credFile})`);
|
|
245
|
+
log(`\nlive: ${url} (health ${ok || "not reachable yet"})\n├─ REST API: ${url}/api/\n└─ Dashboard: ${url}/_/ sign in as ${keepSuperuser ? "the superuser the Worker already had" : `${email} (password in ${credFile})`}`);
|
|
177
246
|
} else log("deployed; workers.dev subdomain not enabled on this account, add a route or enable it in the dashboard (or VOIDBASE_DEPLOY_DOMAIN=<host> / --domain)");
|
|
178
247
|
return { name, account: account.id, url, wranglerConfig, project: cloud };
|
|
179
248
|
}
|
|
249
|
+
|
|
250
|
+
// ---- host-scoped redirects as zone Redirect Rules (Rulesets API, phase http_request_dynamic_redirect) --------------
|
|
251
|
+
// One rule per `_redirects` line, tagged `voidbase:<worker>:` in its description so a redeploy replaces exactly its own
|
|
252
|
+
// rules and leaves the zone's other redirect rules alone. Needs the zone permission Single Redirect (edit) on the deploy
|
|
253
|
+
// token ("Dynamic URL Redirects Write" in the API's permission listing, next to the DNS permission the token may already
|
|
254
|
+
// carry for the zone). Without it the deploy logs the rules to create by hand and carries on.
|
|
255
|
+
const quote = (v: string) => JSON.stringify(v);
|
|
256
|
+
export function redirectRule(worker: string, r: RedirectEntry): Record<string, unknown> {
|
|
257
|
+
const wildcard = r.path.endsWith("/*"); const prefix = wildcard ? r.path.slice(0, -1) : r.path; // "/docs/*" -> "/docs/"
|
|
258
|
+
const expression = wildcard ? (prefix === "/" ? `(http.host eq ${quote(r.host!)})` : `(http.host eq ${quote(r.host!)} and starts_with(http.request.uri.path, ${quote(prefix)}))`) : `(http.host eq ${quote(r.host!)} and http.request.uri.path eq ${quote(r.path)})`;
|
|
259
|
+
const absolute = (to: string) => (/^https?:\/\//.test(to) ? to : `https://${r.host}${to.startsWith("/") ? "" : "/"}${to}`);
|
|
260
|
+
const splat = r.to.includes(":splat");
|
|
261
|
+
const target_url = splat
|
|
262
|
+
? { expression: `concat(${quote(absolute(r.to).replace(":splat", "").replace(/\/$/, ""))}, ${prefix === "/" ? "http.request.uri.path" : `substring(http.request.uri.path, ${prefix.length - 1})`})` }
|
|
263
|
+
: { value: absolute(r.to) };
|
|
264
|
+
return { description: `voidbase:${worker}:${r.source}`, expression, action: "redirect", action_parameters: { from_value: { status_code: r.status, target_url, preserve_query_string: true } }, enabled: true };
|
|
265
|
+
}
|
|
266
|
+
export async function applyZoneRedirects(api: CfApi, account: string, worker: string, entries: RedirectEntry[], log: (l: string) => void): Promise<void> {
|
|
267
|
+
const byZone = new Map<string, { zone: { id: string; name: string }; rules: Record<string, unknown>[] }>();
|
|
268
|
+
for (const r of entries) {
|
|
269
|
+
const zone = await findZone(api, r.host!, account);
|
|
270
|
+
if (!zone) { log(`redirect ${r.source}: no zone on the account covers ${r.host}, rule skipped`); continue; }
|
|
271
|
+
const slot = byZone.get(zone.id) ?? { zone, rules: [] }; slot.rules.push(redirectRule(worker, r)); byZone.set(zone.id, slot);
|
|
272
|
+
}
|
|
273
|
+
for (const { zone, rules } of byZone.values()) {
|
|
274
|
+
const path = `/zones/${zone.id}/rulesets/phases/http_request_dynamic_redirect/entrypoint`;
|
|
275
|
+
try {
|
|
276
|
+
const cur = await api.raw("GET", path); const body = await cur.text();
|
|
277
|
+
if (cur.status === 403 || cur.status === 401) throw new Error("permission");
|
|
278
|
+
const existing = cur.ok ? ((JSON.parse(body) as { result?: { rules?: Record<string, unknown>[] } }).result?.rules ?? []) : [];
|
|
279
|
+
const kept = existing.filter((x) => !String(x.description ?? "").startsWith(`voidbase:${worker}:`));
|
|
280
|
+
await api.json("PUT", path, { rules: [...kept, ...rules] });
|
|
281
|
+
log(`zone ${zone.name}: ${rules.length} redirect rule(s) set (${rules.map((x) => String(x.description).split(":").slice(2).join(":")).join(", ")})`);
|
|
282
|
+
} catch (e) {
|
|
283
|
+
const why = e instanceof Error && e.message === "permission" ? "the token lacks the zone permission Single Redirect > Edit (API name: Dynamic URL Redirects Write) for the zone" : e instanceof Error ? e.message : String(e);
|
|
284
|
+
log(`zone ${zone.name}: redirect rules not set (${why}). Add that permission to the token and deploy again, or create them under Rules > Redirect Rules:\n${rules.map((x) => ` ${x.expression} -> ${JSON.stringify((x.action_parameters as { from_value: { target_url: unknown } }).from_value.target_url)}`).join("\n")}`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|