sproutboat 0.2.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/LICENSE +21 -0
- package/README.md +86 -0
- package/SURFACE.md +49 -0
- package/package.json +50 -0
- package/src/assets.ts +77 -0
- package/src/broker.ts +593 -0
- package/src/build.ts +103 -0
- package/src/compile.ts +132 -0
- package/src/config.ts +241 -0
- package/src/credentials.ts +79 -0
- package/src/main.ts +322 -0
- package/src/manifest.ts +80 -0
- package/src/native-fetch-prelude.js +685 -0
- package/src/patch-porffor.ts +43 -0
- package/src/report.ts +65 -0
- package/src/source.ts +26 -0
- package/src/surface.ts +41 -0
- package/src/toolchain.ts +125 -0
package/src/build.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { cp, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { resolve } from "node:path";
|
|
4
|
+
import { walkAssets, type AssetManifest } from "./assets";
|
|
5
|
+
import type { SproutboatConfig } from "./config";
|
|
6
|
+
import { compileWorker } from "./compile";
|
|
7
|
+
import { ARTIFACT_SCHEMA_VERSION, CAPABILITY_PROFILE, RUNTIME, type ArtifactManifest } from "./manifest";
|
|
8
|
+
import { ensureZig, esbuildVersion, porfforVersion, toolchainStamp } from "./toolchain";
|
|
9
|
+
|
|
10
|
+
export type BuildInput = {
|
|
11
|
+
projectDir: string;
|
|
12
|
+
config: SproutboatConfig;
|
|
13
|
+
sourcePath: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type BuildOutput = {
|
|
17
|
+
artifactDir: string;
|
|
18
|
+
manifest: ArtifactManifest;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function digest(value: Uint8Array | string): `sha256:${string}` {
|
|
22
|
+
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Cross-compile a handler into a linux-x86_64 native-fetch server binary with
|
|
27
|
+
* Porffor + Zig — no Docker. The binary is not run here (the build host may not
|
|
28
|
+
* be linux); the control plane starts it once on deploy and rejects it if it
|
|
29
|
+
* does not come up.
|
|
30
|
+
*/
|
|
31
|
+
export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
|
|
32
|
+
const source = await readFile(input.sourcePath);
|
|
33
|
+
const sourceHash = digest(source);
|
|
34
|
+
const artifactId = sourceHash.slice("sha256:".length, 24);
|
|
35
|
+
const artifactDir = resolve(input.projectDir, ".sproutboat/dist", artifactId);
|
|
36
|
+
const workerPath = resolve(artifactDir, "worker");
|
|
37
|
+
await mkdir(artifactDir, { recursive: true });
|
|
38
|
+
|
|
39
|
+
const bindings = {
|
|
40
|
+
kv: input.config.kv_namespaces ?? [],
|
|
41
|
+
secrets: input.config.secrets ?? [],
|
|
42
|
+
outbound: input.config.outbound ?? [],
|
|
43
|
+
d1: input.config.d1_databases ?? [],
|
|
44
|
+
r2: input.config.r2_buckets ?? [],
|
|
45
|
+
queues: input.config.queues ?? [],
|
|
46
|
+
analytics: input.config.analytics_engine_datasets ?? [],
|
|
47
|
+
do: Object.entries(input.config.durable_objects ?? {}).map(([binding, className]) => ({ binding, className })),
|
|
48
|
+
crons: input.config.triggers?.crons ?? [],
|
|
49
|
+
assets: input.config.assets?.binding ?? "",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const zigBin = await ensureZig();
|
|
53
|
+
await compileWorker({
|
|
54
|
+
sourcePath: input.sourcePath,
|
|
55
|
+
outPath: workerPath,
|
|
56
|
+
vars: input.config.vars ?? {},
|
|
57
|
+
bindings,
|
|
58
|
+
zigBin,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const worker = await readFile(workerPath);
|
|
62
|
+
const manifest: ArtifactManifest = {
|
|
63
|
+
schemaVersion: ARTIFACT_SCHEMA_VERSION,
|
|
64
|
+
project: input.config.name,
|
|
65
|
+
target: "linux-x86_64",
|
|
66
|
+
runtime: RUNTIME,
|
|
67
|
+
capabilityProfile: CAPABILITY_PROFILE,
|
|
68
|
+
porfforVersion: porfforVersion(),
|
|
69
|
+
esbuildVersion: esbuildVersion(),
|
|
70
|
+
buildImage: toolchainStamp(),
|
|
71
|
+
sourceHash,
|
|
72
|
+
binaryHash: digest(worker),
|
|
73
|
+
binarySize: (await stat(workerPath)).size,
|
|
74
|
+
builtAt: new Date().toISOString(),
|
|
75
|
+
};
|
|
76
|
+
await writeFile(resolve(artifactDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
77
|
+
// Bindings live beside the manifest, not in it: the artifact manifest schema is
|
|
78
|
+
// frozen at v2. The control plane reads this to configure the per-deployment
|
|
79
|
+
// broker (KV / D1 / R2 / queue names, secret names, outbound allowlist, cron
|
|
80
|
+
// schedules, Durable Object classes).
|
|
81
|
+
if (Object.values(bindings).some((names) => names.length > 0)) {
|
|
82
|
+
await writeFile(resolve(artifactDir, "bindings.json"), `${JSON.stringify(bindings, null, 2)}\n`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Static assets: copy the directory next to the artifact and record a manifest
|
|
86
|
+
// the edge serves from directly (assets-first) and the broker reads for
|
|
87
|
+
// `env.<ASSETS>.fetch()`.
|
|
88
|
+
if (input.config.assets) {
|
|
89
|
+
const srcDir = resolve(input.projectDir, input.config.assets.directory);
|
|
90
|
+
const outDir = resolve(artifactDir, "assets");
|
|
91
|
+
if (!(await stat(srcDir).then((s) => s.isDirectory()).catch(() => false))) {
|
|
92
|
+
throw new Error(`assets.directory "${input.config.assets.directory}" not found — run your site build first`);
|
|
93
|
+
}
|
|
94
|
+
await cp(srcDir, outDir, { recursive: true });
|
|
95
|
+
const assetManifest: AssetManifest = {
|
|
96
|
+
notFound: input.config.assets.not_found_handling ?? "none",
|
|
97
|
+
runSproutFirst: input.config.assets.run_sprout_first ?? false,
|
|
98
|
+
files: walkAssets(outDir),
|
|
99
|
+
};
|
|
100
|
+
await writeFile(resolve(artifactDir, "assets.json"), `${JSON.stringify(assetManifest, null, 2)}\n`);
|
|
101
|
+
}
|
|
102
|
+
return { artifactDir, manifest };
|
|
103
|
+
}
|
package/src/compile.ts
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compile `export default { fetch }` into a linux-x86_64 native-fetch server
|
|
3
|
+
* binary. Porffor renders the handler to C; with `--musl` it cross-compiles via
|
|
4
|
+
* `zig cc -target x86_64-linux-musl` and statically links, so the same command
|
|
5
|
+
* works from macOS, Linux, or WSL with no Docker.
|
|
6
|
+
*
|
|
7
|
+
* One-time per machine: Porffor git-clones uWebSockets and builds `uSockets.a`
|
|
8
|
+
* into ~/.cache/porffor/deps/ (needs `git` and `make` on PATH). Later builds
|
|
9
|
+
* reuse it and take a few seconds.
|
|
10
|
+
*/
|
|
11
|
+
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
12
|
+
import { dirname, resolve } from "node:path";
|
|
13
|
+
import { ensurePorfforPatched } from "./patch-porffor";
|
|
14
|
+
import { porfforRoot } from "./toolchain";
|
|
15
|
+
|
|
16
|
+
const preludePath = new URL("./native-fetch-prelude.js", import.meta.url);
|
|
17
|
+
// The server honours $PORT at runtime (patches/porffor-render.patch); this baked
|
|
18
|
+
// value is only a fallback for a directly-run binary.
|
|
19
|
+
const DEFAULT_PORT = 8080;
|
|
20
|
+
const COMPILE_TIMEOUT_MS = Number(process.env.SPROUTBOAT_COMPILE_TIMEOUT_MS || 600_000);
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Binding names a project declares. `do` maps a binding name to a Durable Object
|
|
24
|
+
* class name; `crons` are schedule expressions with no name.
|
|
25
|
+
*/
|
|
26
|
+
export type Bindings = {
|
|
27
|
+
kv: string[];
|
|
28
|
+
secrets: string[];
|
|
29
|
+
outbound: string[];
|
|
30
|
+
d1: string[];
|
|
31
|
+
r2: string[];
|
|
32
|
+
queues: string[];
|
|
33
|
+
analytics: string[];
|
|
34
|
+
do: Array<{ binding: string; className: string }>;
|
|
35
|
+
crons: string[];
|
|
36
|
+
/** Static-asset binding name for `env.<NAME>.fetch(request)`; `""` when assets are edge-only. */
|
|
37
|
+
assets: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const EMPTY_BINDINGS: Bindings = { kv: [], secrets: [], outbound: [], d1: [], r2: [], queues: [], analytics: [], do: [], crons: [], assets: "" };
|
|
41
|
+
|
|
42
|
+
function hasBindings(b: Bindings): boolean {
|
|
43
|
+
return (
|
|
44
|
+
b.kv.length > 0 || b.secrets.length > 0 || b.outbound.length > 0 || b.d1.length > 0 || b.r2.length > 0 ||
|
|
45
|
+
b.queues.length > 0 || b.analytics.length > 0 || b.do.length > 0 || b.assets !== ""
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build the final native-fetch module: the prelude (Web API shims + the broker
|
|
51
|
+
* binding shim + the trigger dispatcher), then `const env = {…}` with the baked
|
|
52
|
+
* `vars`, then — if any binding is declared — one `__sbInstallBindings(env, …)`
|
|
53
|
+
* line, then the user's source with its `export` keywords neutralised (so its
|
|
54
|
+
* `export default {…}` becomes a plain object we can hand to the dispatcher),
|
|
55
|
+
* then our single `export default { fetch }` that routes every request through
|
|
56
|
+
* `__sbEntry` (HTTP → `handlers.fetch`; `x-sb-trigger` → scheduled / queue / DO).
|
|
57
|
+
*
|
|
58
|
+
* With no bindings and no `scheduled`/`queue`/DO the output behaves exactly like
|
|
59
|
+
* a plain `export default { fetch }` worker.
|
|
60
|
+
*/
|
|
61
|
+
export function wrapNativeFetchHandler(
|
|
62
|
+
source: string,
|
|
63
|
+
prelude: string,
|
|
64
|
+
vars: Record<string, string> = {},
|
|
65
|
+
bindings: Bindings = EMPTY_BINDINGS,
|
|
66
|
+
): string {
|
|
67
|
+
if (!/\bexport\s+default\s*\{/.test(source) || !/\bfetch\s*\(/.test(source)) {
|
|
68
|
+
throw new Error("handler must default-export an object with a fetch(request) method");
|
|
69
|
+
}
|
|
70
|
+
// Neutralise the module's exports: its default object becomes `__sbHandlers`,
|
|
71
|
+
// and any `export class`/`function`/`const` (Durable Object classes, helpers)
|
|
72
|
+
// becomes a plain top-level declaration. Imports are already rejected upstream.
|
|
73
|
+
const neutralised = source
|
|
74
|
+
.replace(/^(\s*)export\s+default\s*/m, "$1const __sbHandlers = ")
|
|
75
|
+
.replace(/^export\s+(async\s+function|function|class|const|let|var)\b/gm, "$1");
|
|
76
|
+
|
|
77
|
+
const env = `const env = ${JSON.stringify(vars)};\nglobalThis.env = env;\n`;
|
|
78
|
+
const wire = hasBindings(bindings) ? `__sbInstallBindings(env, ${JSON.stringify(bindings)});\n` : "";
|
|
79
|
+
const registerDO = bindings.do.length
|
|
80
|
+
? `__sbRegisterDO({ ${bindings.do.map((d) => `${d.className}: ${d.className}`).join(", ")} });\n`
|
|
81
|
+
: "";
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
`${prelude}\n${env}${wire}` +
|
|
85
|
+
`${neutralised}\n` +
|
|
86
|
+
`${registerDO}` +
|
|
87
|
+
`export default {\n port: ${DEFAULT_PORT},\n fetch(request) { return __sbEntry(__sbHandlers, request); }\n};\n`
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type CompileInput = {
|
|
92
|
+
sourcePath: string;
|
|
93
|
+
outPath: string;
|
|
94
|
+
vars: Record<string, string>;
|
|
95
|
+
bindings?: Bindings;
|
|
96
|
+
zigBin: string;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/** Compile `sourcePath` to a native binary at `outPath` (mode 0555). */
|
|
100
|
+
export async function compileWorker(input: CompileInput): Promise<void> {
|
|
101
|
+
await ensurePorfforPatched();
|
|
102
|
+
const outDir = dirname(input.outPath);
|
|
103
|
+
await mkdir(outDir, { recursive: true });
|
|
104
|
+
const generatedPath = resolve(outDir, "worker.generated.js");
|
|
105
|
+
const [source, prelude] = await Promise.all([readFile(input.sourcePath, "utf8"), readFile(preludePath, "utf8")]);
|
|
106
|
+
await writeFile(generatedPath, wrapNativeFetchHandler(source, prelude, input.vars, input.bindings ?? EMPTY_BINDINGS));
|
|
107
|
+
|
|
108
|
+
const porffor = porfforRoot();
|
|
109
|
+
const launcher = resolve(porffor, "runtime/index.js");
|
|
110
|
+
// Porffor shells bare `zig` and `esbuild`; put both on PATH for the child.
|
|
111
|
+
const binDir = resolve(porffor, "../.bin");
|
|
112
|
+
const path = `${dirname(input.zigBin)}:${binDir}:${process.env.PATH ?? ""}`;
|
|
113
|
+
|
|
114
|
+
const child = Bun.spawn(
|
|
115
|
+
[process.execPath, launcher, "native", generatedPath, "-o", input.outPath, "--musl"],
|
|
116
|
+
{ cwd: outDir, stdout: "pipe", stderr: "pipe", env: { ...process.env, PATH: path } },
|
|
117
|
+
);
|
|
118
|
+
let timedOut = false;
|
|
119
|
+
const timer = setTimeout(() => { timedOut = true; child.kill(); }, COMPILE_TIMEOUT_MS);
|
|
120
|
+
const [code, stdout, stderr] = await Promise.all([
|
|
121
|
+
child.exited,
|
|
122
|
+
new Response(child.stdout).text(),
|
|
123
|
+
new Response(child.stderr).text(),
|
|
124
|
+
]);
|
|
125
|
+
clearTimeout(timer);
|
|
126
|
+
|
|
127
|
+
if (timedOut) throw new Error(`compile timed out after ${COMPILE_TIMEOUT_MS}ms`);
|
|
128
|
+
if (code !== 0 || !(await Bun.file(input.outPath).exists())) {
|
|
129
|
+
throw new Error(`Porffor compile failed: ${(stderr || stdout).trim() || `exit ${code}`}`);
|
|
130
|
+
}
|
|
131
|
+
await chmod(input.outPath, 0o555);
|
|
132
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
const slugPattern = /^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])?$/;
|
|
2
|
+
|
|
3
|
+
export type SproutboatConfig = {
|
|
4
|
+
$schema?: string;
|
|
5
|
+
name: string;
|
|
6
|
+
main: string;
|
|
7
|
+
compatibility_date: string;
|
|
8
|
+
vars?: Record<string, string>;
|
|
9
|
+
/** KV namespace binding names, exposed as `env.<NAME>`. */
|
|
10
|
+
kv_namespaces?: string[];
|
|
11
|
+
/** Secret binding names, exposed as `env.<NAME>` (value fetched at use). */
|
|
12
|
+
secrets?: string[];
|
|
13
|
+
/** Hostnames the worker's `fetch()` may reach (exact host match). */
|
|
14
|
+
outbound?: string[];
|
|
15
|
+
/** D1 (SQLite) database binding names, exposed as `env.<NAME>`. */
|
|
16
|
+
d1_databases?: string[];
|
|
17
|
+
/** R2 (object storage) bucket binding names, exposed as `env.<NAME>`. */
|
|
18
|
+
r2_buckets?: string[];
|
|
19
|
+
/** Queue producer binding names, exposed as `env.<NAME>.send()`. A `queue(batch)` handler consumes them. */
|
|
20
|
+
queues?: string[];
|
|
21
|
+
/** Analytics Engine dataset binding names, exposed as `env.<NAME>.writeDataPoint()`. */
|
|
22
|
+
analytics_engine_datasets?: string[];
|
|
23
|
+
/** Durable Object bindings: `{ BINDING_NAME: "ClassName" }`. The class is defined in the handler module. */
|
|
24
|
+
durable_objects?: Record<string, string>;
|
|
25
|
+
/** Scheduled triggers, e.g. `{ "crons": ["0 3 * * *"] }` — a `scheduled(event)` handler runs on each tick. */
|
|
26
|
+
triggers?: { crons?: string[] };
|
|
27
|
+
/** Static assets: a directory served edge-first (like Cloudflare), optionally bound as `env.<BINDING>.fetch(request)`. */
|
|
28
|
+
assets?: AssetsConfig;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type AssetsConfig = {
|
|
32
|
+
/** Project-relative directory of files to publish with the artifact. */
|
|
33
|
+
directory: string;
|
|
34
|
+
/** Optional binding name for `env.<BINDING>.fetch(request)`. */
|
|
35
|
+
binding?: string;
|
|
36
|
+
/** What to serve when a request matches no file (applied by the broker on `env.<BINDING>.fetch`). */
|
|
37
|
+
not_found_handling?: "none" | "single-page-application" | "404-page";
|
|
38
|
+
/** `true` = run the worker before serving any asset; string[] = selective route patterns (`!` negates). */
|
|
39
|
+
run_sprout_first?: boolean | string[];
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type ConfigValidation =
|
|
43
|
+
| { ok: true; value: SproutboatConfig }
|
|
44
|
+
| { ok: false; errors: string[] };
|
|
45
|
+
|
|
46
|
+
type JsonValue = string | number | boolean | null | ConfigJsonObject | JsonValue[];
|
|
47
|
+
|
|
48
|
+
interface ConfigJsonObject {
|
|
49
|
+
readonly [key: string]: JsonValue;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type ConfigInput = JsonValue | undefined;
|
|
53
|
+
|
|
54
|
+
function isRecord(value: ConfigInput): value is ConfigJsonObject {
|
|
55
|
+
return value !== null && Object(value) === value && !Array.isArray(value)
|
|
56
|
+
&& !(value instanceof Function);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isString(value: ConfigInput): value is string {
|
|
60
|
+
return Object(value) !== value && value === String(value);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function isProjectSlug(value: string): boolean {
|
|
64
|
+
return slugPattern.test(value);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function validateConfig(value: ConfigInput): ConfigValidation {
|
|
68
|
+
const errors: string[] = [];
|
|
69
|
+
if (!isRecord(value)) return { ok: false, errors: ["config must be an object"] };
|
|
70
|
+
const allowed = new Set([
|
|
71
|
+
"$schema", "name", "main", "compatibility_date", "vars",
|
|
72
|
+
"kv_namespaces", "secrets", "outbound", "d1_databases", "r2_buckets",
|
|
73
|
+
"queues", "analytics_engine_datasets", "durable_objects", "triggers", "assets",
|
|
74
|
+
]);
|
|
75
|
+
for (const key of Object.keys(value)) if (!allowed.has(key)) errors.push(`unsupported config field: ${key}`);
|
|
76
|
+
const name = isString(value.name) && isProjectSlug(value.name) ? value.name : null;
|
|
77
|
+
if (name === null) {
|
|
78
|
+
errors.push("name must be a 3–32 character lowercase slug");
|
|
79
|
+
}
|
|
80
|
+
const main = isString(value.main) && value.main.startsWith("src/") && !value.main.includes("..") ? value.main : null;
|
|
81
|
+
if (main === null) {
|
|
82
|
+
errors.push("main must be a relative entry point under src/");
|
|
83
|
+
}
|
|
84
|
+
const compatibility_date = isString(value.compatibility_date) && /^\d{4}-\d{2}-\d{2}$/.test(value.compatibility_date) ? value.compatibility_date : null;
|
|
85
|
+
if (compatibility_date === null) {
|
|
86
|
+
errors.push("compatibility_date must use YYYY-MM-DD");
|
|
87
|
+
}
|
|
88
|
+
const schema = value.$schema === undefined ? undefined : isString(value.$schema) ? value.$schema : null;
|
|
89
|
+
if (schema === null) errors.push("$schema must be a string");
|
|
90
|
+
let vars: Record<string, string> | undefined;
|
|
91
|
+
if (value.vars !== undefined) {
|
|
92
|
+
if (!isRecord(value.vars)) errors.push("vars must be an object of plain string values");
|
|
93
|
+
else {
|
|
94
|
+
vars = {};
|
|
95
|
+
for (const [key, item] of Object.entries(value.vars)) {
|
|
96
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(key) || !isString(item)) errors.push(`vars.${key} must be a string environment name`);
|
|
97
|
+
else vars[key] = item;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const bindingName = /^[A-Z][A-Z0-9_]*$/;
|
|
102
|
+
const hostPattern = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
|
|
103
|
+
const stringArray = (
|
|
104
|
+
field: "kv_namespaces" | "secrets" | "outbound" | "d1_databases" | "r2_buckets" | "queues" | "analytics_engine_datasets",
|
|
105
|
+
item: RegExp,
|
|
106
|
+
label: string,
|
|
107
|
+
): string[] | undefined => {
|
|
108
|
+
if (value[field] === undefined) return undefined;
|
|
109
|
+
const raw = value[field];
|
|
110
|
+
if (!Array.isArray(raw)) {
|
|
111
|
+
errors.push(`${field} must be an array of ${label}`);
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
const out: string[] = [];
|
|
115
|
+
for (const entry of raw) {
|
|
116
|
+
if (!isString(entry) || !item.test(entry)) errors.push(`${field} entries must be ${label}`);
|
|
117
|
+
else out.push(entry);
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
};
|
|
121
|
+
const kv_namespaces = stringArray("kv_namespaces", bindingName, "binding names (UPPER_SNAKE_CASE)");
|
|
122
|
+
const secrets = stringArray("secrets", bindingName, "binding names (UPPER_SNAKE_CASE)");
|
|
123
|
+
const outbound = stringArray("outbound", hostPattern, "hostnames");
|
|
124
|
+
const d1_databases = stringArray("d1_databases", bindingName, "binding names (UPPER_SNAKE_CASE)");
|
|
125
|
+
const r2_buckets = stringArray("r2_buckets", bindingName, "binding names (UPPER_SNAKE_CASE)");
|
|
126
|
+
const queues = stringArray("queues", bindingName, "binding names (UPPER_SNAKE_CASE)");
|
|
127
|
+
const analytics_engine_datasets = stringArray("analytics_engine_datasets", bindingName, "binding names (UPPER_SNAKE_CASE)");
|
|
128
|
+
|
|
129
|
+
let durable_objects: Record<string, string> | undefined;
|
|
130
|
+
if (value.durable_objects !== undefined) {
|
|
131
|
+
if (!isRecord(value.durable_objects)) {
|
|
132
|
+
errors.push("durable_objects must be an object of { BINDING_NAME: \"ClassName\" }");
|
|
133
|
+
} else {
|
|
134
|
+
durable_objects = {};
|
|
135
|
+
for (const [binding, className] of Object.entries(value.durable_objects)) {
|
|
136
|
+
if (!bindingName.test(binding) || !isString(className) || !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(className)) {
|
|
137
|
+
errors.push(`durable_objects.${binding} must map an UPPER_SNAKE binding to a class identifier`);
|
|
138
|
+
} else durable_objects[binding] = className;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
let triggers: { crons?: string[] } | undefined;
|
|
144
|
+
if (value.triggers !== undefined) {
|
|
145
|
+
if (!isRecord(value.triggers)) {
|
|
146
|
+
errors.push("triggers must be an object with an optional `crons` array");
|
|
147
|
+
} else {
|
|
148
|
+
triggers = {};
|
|
149
|
+
if (value.triggers.crons !== undefined) {
|
|
150
|
+
const raw = value.triggers.crons;
|
|
151
|
+
if (!Array.isArray(raw) || raw.some((c) => !isString(c) || c.trim().split(/\s+/).length !== 5)) {
|
|
152
|
+
errors.push("triggers.crons must be an array of 5-field cron expressions");
|
|
153
|
+
} else triggers.crons = raw.map((c) => String(c).trim());
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
let assets: AssetsConfig | undefined;
|
|
159
|
+
if (value.assets !== undefined) {
|
|
160
|
+
if (!isRecord(value.assets)) {
|
|
161
|
+
errors.push("assets must be an object with a `directory`");
|
|
162
|
+
} else {
|
|
163
|
+
const raw = value.assets;
|
|
164
|
+
const dir = isString(raw.directory) && raw.directory.length > 0 && !raw.directory.includes("..")
|
|
165
|
+
? raw.directory.replace(/^\.\//, "").replace(/\/$/, "")
|
|
166
|
+
: null;
|
|
167
|
+
if (dir === null) errors.push("assets.directory must be a project-relative path");
|
|
168
|
+
const binding = raw.binding === undefined ? undefined
|
|
169
|
+
: isString(raw.binding) && bindingName.test(raw.binding) ? raw.binding : null;
|
|
170
|
+
if (binding === null) errors.push("assets.binding must be a binding name (UPPER_SNAKE_CASE)");
|
|
171
|
+
const nfh = raw.not_found_handling === "none" || raw.not_found_handling === "single-page-application"
|
|
172
|
+
|| raw.not_found_handling === "404-page" ? raw.not_found_handling : undefined;
|
|
173
|
+
if (raw.not_found_handling !== undefined && nfh === undefined) {
|
|
174
|
+
errors.push('assets.not_found_handling must be "none", "single-page-application", or "404-page"');
|
|
175
|
+
}
|
|
176
|
+
let rwf: boolean | string[] | undefined;
|
|
177
|
+
const rwfRaw = raw.run_sprout_first;
|
|
178
|
+
if (rwfRaw === true || rwfRaw === false) rwf = rwfRaw;
|
|
179
|
+
else if (Array.isArray(rwfRaw) && rwfRaw.every((p) => isString(p) && /^!?\//.test(p))) {
|
|
180
|
+
rwf = rwfRaw.map((p) => String(p));
|
|
181
|
+
} else if (rwfRaw !== undefined) {
|
|
182
|
+
errors.push("assets.run_sprout_first must be a boolean or an array of route patterns");
|
|
183
|
+
}
|
|
184
|
+
if (dir !== null && binding !== null) {
|
|
185
|
+
assets = { directory: dir };
|
|
186
|
+
if (binding !== undefined) assets.binding = binding;
|
|
187
|
+
if (nfh !== undefined) assets.not_found_handling = nfh;
|
|
188
|
+
if (rwf !== undefined) assets.run_sprout_first = rwf;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const bindingSlots = [
|
|
194
|
+
...(kv_namespaces ?? []), ...(secrets ?? []), ...(d1_databases ?? []), ...(r2_buckets ?? []),
|
|
195
|
+
...(queues ?? []), ...(analytics_engine_datasets ?? []), ...Object.keys(durable_objects ?? {}), ...Object.keys(vars ?? {}),
|
|
196
|
+
...(assets?.binding ? [assets.binding] : []),
|
|
197
|
+
];
|
|
198
|
+
if (new Set(bindingSlots).size !== bindingSlots.length) errors.push("vars and binding names must not collide");
|
|
199
|
+
|
|
200
|
+
if (errors.length || name === null || main === null || compatibility_date === null || schema === null) return { ok: false, errors };
|
|
201
|
+
const config: SproutboatConfig = { name, main, compatibility_date };
|
|
202
|
+
if ("$schema" in value) config.$schema = schema;
|
|
203
|
+
if ("vars" in value) config.vars = vars;
|
|
204
|
+
if ("kv_namespaces" in value) config.kv_namespaces = kv_namespaces;
|
|
205
|
+
if ("secrets" in value) config.secrets = secrets;
|
|
206
|
+
if ("outbound" in value) config.outbound = outbound;
|
|
207
|
+
if ("d1_databases" in value) config.d1_databases = d1_databases;
|
|
208
|
+
if ("r2_buckets" in value) config.r2_buckets = r2_buckets;
|
|
209
|
+
if ("queues" in value) config.queues = queues;
|
|
210
|
+
if ("analytics_engine_datasets" in value) config.analytics_engine_datasets = analytics_engine_datasets;
|
|
211
|
+
if ("durable_objects" in value) config.durable_objects = durable_objects;
|
|
212
|
+
if ("triggers" in value) config.triggers = triggers;
|
|
213
|
+
if ("assets" in value) config.assets = assets;
|
|
214
|
+
return { ok: true, value: config };
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function parseConfig(source: string): ConfigValidation {
|
|
218
|
+
try {
|
|
219
|
+
// Config files intentionally support comments and trailing commas, but not
|
|
220
|
+
// arbitrary JavaScript expressions.
|
|
221
|
+
let json = "";
|
|
222
|
+
let quoted = false;
|
|
223
|
+
for (let index = 0; index < source.length; index++) {
|
|
224
|
+
const character = source[index];
|
|
225
|
+
if (character === '"' && source[index - 1] !== "\\") quoted = !quoted;
|
|
226
|
+
if (!quoted && character === "/" && source[index + 1] === "/") {
|
|
227
|
+
index = source.indexOf("\n", index);
|
|
228
|
+
if (index < 0) break;
|
|
229
|
+
json += "\n";
|
|
230
|
+
} else if (!quoted && character === "/" && source[index + 1] === "*") {
|
|
231
|
+
index = source.indexOf("*/", index + 2);
|
|
232
|
+
if (index < 0) throw new SyntaxError("unterminated block comment");
|
|
233
|
+
index++;
|
|
234
|
+
} else json += character;
|
|
235
|
+
}
|
|
236
|
+
json = json.replace(/,\s*([}\]])/g, "$1");
|
|
237
|
+
return validateConfig(JSON.parse(json));
|
|
238
|
+
} catch (error) {
|
|
239
|
+
return { ok: false, errors: [`invalid sproutboat.jsonc: ${error instanceof Error ? error.message : String(error)}`] };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { chmod, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { isAbsolute, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
type Credentials = { version: 1; activeApiUrl?: string; profiles: Record<string, { token: string }> };
|
|
6
|
+
|
|
7
|
+
type JsonValue = string | number | boolean | null | JsonObject | JsonValue[];
|
|
8
|
+
type JsonObject = { [key: string]: JsonValue };
|
|
9
|
+
|
|
10
|
+
function isString(value: JsonValue | undefined): value is string {
|
|
11
|
+
return value !== undefined && value === String(value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type CredentialsInput = JsonObject;
|
|
15
|
+
type ProfileInput = JsonObject;
|
|
16
|
+
|
|
17
|
+
function parseCredentials(value: JsonValue): Credentials | undefined {
|
|
18
|
+
if (!(value instanceof Object) || Array.isArray(value)) return undefined;
|
|
19
|
+
const input: CredentialsInput = value;
|
|
20
|
+
if (input.version !== 1 || !(input.profiles instanceof Object) || Array.isArray(input.profiles)) return undefined;
|
|
21
|
+
const profiles: Record<string, { token: string }> = {};
|
|
22
|
+
for (const [apiUrl, profileValue] of Object.entries(input.profiles)) {
|
|
23
|
+
if (!(profileValue instanceof Object) || Array.isArray(profileValue)) return undefined;
|
|
24
|
+
const profile: ProfileInput = profileValue;
|
|
25
|
+
if (!isString(profile.token)) return undefined;
|
|
26
|
+
profiles[apiUrl] = { token: profile.token };
|
|
27
|
+
}
|
|
28
|
+
return { version: 1, activeApiUrl: isString(input.activeApiUrl) ? input.activeApiUrl : undefined, profiles };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function configDirectory(): string {
|
|
32
|
+
const configured = process.env.SPROUTBOAT_CONFIG_DIR || process.env.XDG_CONFIG_HOME;
|
|
33
|
+
if (configured && isAbsolute(configured)) return resolve(configured, "sproutboat");
|
|
34
|
+
return resolve(homedir(), ".config", "sproutboat");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function credentialsPath(): string {
|
|
38
|
+
return resolve(configDirectory(), "credentials.json");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function emptyCredentials(): Credentials {
|
|
42
|
+
return { version: 1, profiles: {} };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function readCredentials(): Promise<Credentials> {
|
|
46
|
+
try {
|
|
47
|
+
return parseCredentials(JSON.parse(await readFile(credentialsPath(), "utf8"))) || emptyCredentials();
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT") return emptyCredentials();
|
|
50
|
+
throw new Error("could not read local Sproutboat credentials");
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function savedToken(apiUrl: string): Promise<string | undefined> {
|
|
55
|
+
return (await readCredentials()).profiles[apiUrl]?.token;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function activeApiUrl(): Promise<string | undefined> {
|
|
59
|
+
return (await readCredentials()).activeApiUrl;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export async function saveToken(apiUrl: string, token: string): Promise<void> {
|
|
63
|
+
const directory = configDirectory();
|
|
64
|
+
const path = credentialsPath();
|
|
65
|
+
const credentials = await readCredentials();
|
|
66
|
+
credentials.profiles[apiUrl] = { token };
|
|
67
|
+
credentials.activeApiUrl = apiUrl;
|
|
68
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
69
|
+
await chmod(directory, 0o700);
|
|
70
|
+
const temporary = `${path}.tmp-${crypto.randomUUID()}`;
|
|
71
|
+
try {
|
|
72
|
+
await writeFile(temporary, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 });
|
|
73
|
+
await chmod(temporary, 0o600);
|
|
74
|
+
await rename(temporary, path);
|
|
75
|
+
await chmod(path, 0o600);
|
|
76
|
+
} finally {
|
|
77
|
+
await rm(temporary, { force: true });
|
|
78
|
+
}
|
|
79
|
+
}
|