sproutboat 0.8.0 → 0.10.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 +365 -0
- package/README.md +49 -4
- package/SURFACE.md +12 -5
- package/bin/sproutboat.cjs +32 -0
- package/package.json +27 -10
- package/scripts/build-cli.ts +44 -0
- package/src/assets.ts +4 -121
- package/src/broker.ts +4 -1139
- package/src/build.ts +31 -16
- package/src/compile.ts +80 -28
- package/src/config.ts +4 -379
- package/src/dev.ts +263 -60
- package/src/json.ts +3 -38
- package/src/main.ts +269 -16
- package/src/manifest.ts +4 -167
- package/src/patch-porffor.ts +5 -144
- package/src/porffor-toolchain.ts +5 -0
- package/src/report.ts +5 -7
- package/src/source.ts +4 -44
- package/src/surface.ts +33 -3
- package/src/toolchain.ts +228 -44
- package/src/types-gen.ts +93 -0
- package/src/wrap.ts +5 -267
- package/types/sproutboat.d.ts +248 -0
- package/src/native-fetch-prelude.js +0 -950
- package/src/transport-broker.js +0 -290
- package/src/transport-embedded.js +0 -1567
package/src/build.ts
CHANGED
|
@@ -35,6 +35,17 @@ export type BuildInput = {
|
|
|
35
35
|
/** #15 — `embedded` compiles SQLite into the sprout instead of a broker
|
|
36
36
|
* transport. Defaults to the broker transport. */
|
|
37
37
|
transport?: Transport;
|
|
38
|
+
/**
|
|
39
|
+
* `dev` compiles at -O0, which is roughly three times faster and produces a
|
|
40
|
+
* bigger binary. Only for artifacts that cannot be deployed: `deploy` refuses
|
|
41
|
+
* a host build already, and this defaults to `release` so a caller has to ask
|
|
42
|
+
* for the fast path deliberately.
|
|
43
|
+
*/
|
|
44
|
+
optimize?: "dev" | "release";
|
|
45
|
+
/** Reuse a previously compiled native sprout when only sidecar assets changed. */
|
|
46
|
+
reuseSproutPath?: string;
|
|
47
|
+
/** An isolated artifact directory, used by dev candidates to avoid mixing snapshots. */
|
|
48
|
+
outputDirectory?: string;
|
|
38
49
|
};
|
|
39
50
|
|
|
40
51
|
export type BuildOutput = {
|
|
@@ -60,7 +71,7 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
|
|
|
60
71
|
const source = input.source ?? (await readFile(input.sourcePath));
|
|
61
72
|
const sourceHash = digest(source);
|
|
62
73
|
const artifactId = sourceHash.slice("sha256:".length, 24);
|
|
63
|
-
const artifactDir = resolve(input.projectDir, ".sproutboat/dist", artifactId);
|
|
74
|
+
const artifactDir = input.outputDirectory ?? resolve(input.projectDir, ".sproutboat/dist", artifactId);
|
|
64
75
|
const sproutPath = resolve(artifactDir, "sprout");
|
|
65
76
|
await mkdir(artifactDir, { recursive: true });
|
|
66
77
|
|
|
@@ -88,6 +99,7 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
|
|
|
88
99
|
do: Object.entries(input.config.durable_objects ?? {}).map(([binding, className]) => ({ binding, className })),
|
|
89
100
|
services: input.config.services ?? [],
|
|
90
101
|
crons: input.config.triggers?.crons ?? [],
|
|
102
|
+
ratelimiters: input.config.ratelimiters ?? [],
|
|
91
103
|
assets: input.config.assets?.binding ?? "",
|
|
92
104
|
// Baked plain values, read as env.NAME. The broker never serves these (they
|
|
93
105
|
// are compiled in via SPROUTBOAT_VARS_JSON); they ride along so the control
|
|
@@ -138,21 +150,24 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
|
|
|
138
150
|
bakedAssets = { manifest, files };
|
|
139
151
|
}
|
|
140
152
|
|
|
141
|
-
await
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
153
|
+
if (input.reuseSproutPath) await cp(input.reuseSproutPath, sproutPath);
|
|
154
|
+
else
|
|
155
|
+
await compileSprout({
|
|
156
|
+
sourcePath: input.sourcePath,
|
|
157
|
+
source: input.source,
|
|
158
|
+
outPath: sproutPath,
|
|
159
|
+
vars: input.config.vars ?? {},
|
|
160
|
+
bindings,
|
|
161
|
+
zigBin,
|
|
162
|
+
target: input.target,
|
|
163
|
+
compatibilityDate: input.config.compatibility_date,
|
|
164
|
+
transport: input.transport,
|
|
165
|
+
appName: input.config.name,
|
|
166
|
+
assets: bakedAssets,
|
|
167
|
+
extraLink,
|
|
168
|
+
extraCflags,
|
|
169
|
+
optimize: input.optimize,
|
|
170
|
+
});
|
|
156
171
|
|
|
157
172
|
const sprout = await readFile(sproutPath);
|
|
158
173
|
const manifest: ArtifactManifest = {
|
package/src/compile.ts
CHANGED
|
@@ -7,12 +7,25 @@
|
|
|
7
7
|
* One-time per machine: the uWebSockets tree Porffor links is unpacked into
|
|
8
8
|
* ~/.cache/porffor/deps/. `ensureUWebSockets()` extracts the prebuilt archive
|
|
9
9
|
* shipped in `vendor/` so this needs no `git` or `make`; if that archive is
|
|
10
|
-
* unusable it
|
|
10
|
+
* unusable it stops with its specific integrity or archive error. Contributors
|
|
11
|
+
* may explicitly opt into Porffor's source fallback.
|
|
11
12
|
*/
|
|
13
|
+
import { existsSync } from "node:fs";
|
|
12
14
|
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
13
15
|
import { dirname, resolve } from "node:path";
|
|
14
16
|
import { ensurePorfforPatched } from "./patch-porffor";
|
|
15
|
-
import { ensureUWebSockets,
|
|
17
|
+
import { ensureUWebSockets, ensureUWebSocketsHost, UwsUnavailableError } from "./toolchain";
|
|
18
|
+
import { ensurePorffor } from "./porffor-toolchain";
|
|
19
|
+
// The prelude + transports live in @sproutboat/runtime (next to wrap.ts, which
|
|
20
|
+
// locates them by file URL). `preludePath`/`transportPath` read them from there
|
|
21
|
+
// for a dev build; these text imports bake the same files into the `--compile`
|
|
22
|
+
// binary, where there is no filesystem to read.
|
|
23
|
+
// @ts-expect-error Bun's text loader supplies a string, while TypeScript resolves the JavaScript source itself.
|
|
24
|
+
import embeddedPrelude from "@sproutboat/runtime/src/native-fetch-prelude.js" with { type: "text" };
|
|
25
|
+
// @ts-expect-error See above.
|
|
26
|
+
import embeddedBrokerTransport from "@sproutboat/runtime/src/transport-broker.js" with { type: "text" };
|
|
27
|
+
// @ts-expect-error See above.
|
|
28
|
+
import embeddedStandaloneTransport from "@sproutboat/runtime/src/transport-embedded.js" with { type: "text" };
|
|
16
29
|
import {
|
|
17
30
|
EMPTY_BINDINGS,
|
|
18
31
|
preludePath,
|
|
@@ -22,6 +35,7 @@ import {
|
|
|
22
35
|
type Bindings,
|
|
23
36
|
type Transport,
|
|
24
37
|
} from "./wrap";
|
|
38
|
+
const PACKAGED = import.meta.url.includes("/$bunfs/");
|
|
25
39
|
export type { Transport } from "./wrap";
|
|
26
40
|
|
|
27
41
|
export {
|
|
@@ -54,6 +68,16 @@ export type CompileInput = {
|
|
|
54
68
|
extraLink?: string[];
|
|
55
69
|
/** #15 — flags for the compile step, so inline C can include BearSSL's header. */
|
|
56
70
|
extraCflags?: string[];
|
|
71
|
+
/**
|
|
72
|
+
* How hard the C compiler works on the generated code.
|
|
73
|
+
*
|
|
74
|
+
* Porffor defaults to `-O3` (compiler/index.js reads `-O` from its own argv),
|
|
75
|
+
* which costs most of the build: `examples/hello` is 8.4s at -O3 against 2.9s
|
|
76
|
+
* at -O0 on an M-series host. `dev` pays that on every edit for a binary that
|
|
77
|
+
* never leaves the machine, so it compiles at -O0 and takes a ~55% larger
|
|
78
|
+
* file. Anything that could be deployed stays at -O3.
|
|
79
|
+
*/
|
|
80
|
+
optimize?: "dev" | "release";
|
|
57
81
|
/** Cross-compiler for `linux-x86_64`. Not needed, and not used, for `host`. */
|
|
58
82
|
zigBin?: string;
|
|
59
83
|
/**
|
|
@@ -73,9 +97,13 @@ export type CompileInput = {
|
|
|
73
97
|
* `native-fetch-prelude.js` alone yields a module with no `__sbCall` at all.
|
|
74
98
|
*/
|
|
75
99
|
export async function loadPrelude(transport: Transport = "broker"): Promise<string> {
|
|
76
|
-
const [core, chosen] =
|
|
100
|
+
const [core, chosen] = PACKAGED
|
|
101
|
+
? [embeddedPrelude, transport === "embedded" ? embeddedStandaloneTransport : embeddedBrokerTransport]
|
|
102
|
+
: await Promise.all([readFile(preludePath, "utf8"), readFile(transportPath(transport), "utf8")]);
|
|
77
103
|
if (!core.includes(TRANSPORT_MARKER)) {
|
|
78
|
-
throw new Error(
|
|
104
|
+
throw new Error(
|
|
105
|
+
"prelude is missing its transport marker — @sproutboat/runtime/src/native-fetch-prelude.js changed shape",
|
|
106
|
+
);
|
|
79
107
|
}
|
|
80
108
|
return core.replace(TRANSPORT_MARKER, chosen);
|
|
81
109
|
}
|
|
@@ -88,22 +116,51 @@ function compileEnv(path: string, extraLink?: string[], extraCflags?: string[])
|
|
|
88
116
|
return { ...process.env, PATH: path, SB_EXTRA_LINK: link, SB_EXTRA_CFLAGS: cflags };
|
|
89
117
|
}
|
|
90
118
|
|
|
119
|
+
/**
|
|
120
|
+
* The argument list handed to Porffor, minus the launcher.
|
|
121
|
+
*
|
|
122
|
+
* Exported for the test: the one thing worth pinning is that `-O0` reaches only
|
|
123
|
+
* a build that cannot be deployed. `-s` strips at link, because the unstripped
|
|
124
|
+
* static-musl binary is ~90% DWARF that nothing needs at runtime (12 MB down to
|
|
125
|
+
* ~1.3 MB for the kitchen-sink). `--musl` is what makes it a cross-compile; a
|
|
126
|
+
* host build omits it and Porffor targets the machine it runs on.
|
|
127
|
+
*/
|
|
128
|
+
export function porfforArgs(
|
|
129
|
+
generatedPath: string,
|
|
130
|
+
outPath: string,
|
|
131
|
+
target: CompileInput["target"],
|
|
132
|
+
optimize: CompileInput["optimize"],
|
|
133
|
+
): string[] {
|
|
134
|
+
const args = ["native", generatedPath, "-o", outPath];
|
|
135
|
+
if (target !== "host") args.push("--musl");
|
|
136
|
+
args.push("-s");
|
|
137
|
+
// Porffor reads -O from its own argv and defaults to -O3. -O0 is roughly
|
|
138
|
+
// three times faster to compile, so dev takes it and everything else does
|
|
139
|
+
// not: a deployable artifact is never built with it.
|
|
140
|
+
if (optimize === "dev" && target === "host") args.push("-O0");
|
|
141
|
+
return args;
|
|
142
|
+
}
|
|
143
|
+
|
|
91
144
|
/** Compile `sourcePath` to a native binary at `outPath` (mode 0555). */
|
|
92
145
|
export async function compileSprout(input: CompileInput): Promise<void> {
|
|
93
|
-
await
|
|
146
|
+
const porffor = await ensurePorffor();
|
|
147
|
+
await ensurePorfforPatched(porffor);
|
|
94
148
|
|
|
95
149
|
// Seed the Porffor uWebSockets cache from the prebuilt archive in `vendor/` so
|
|
96
|
-
// the first build needs no `git` / `make`.
|
|
97
|
-
//
|
|
150
|
+
// the first build needs no `git` / `make`. The two targets keep separate
|
|
151
|
+
// cache trees and separate uSockets.a, so seed whichever this build wants.
|
|
152
|
+
// Fall back to Porffor's own git+make path if the archive is unusable.
|
|
98
153
|
try {
|
|
99
|
-
await
|
|
154
|
+
if (input.target === "host") await ensureUWebSocketsHost();
|
|
155
|
+
else await ensureUWebSockets();
|
|
100
156
|
} catch (error) {
|
|
101
157
|
if (!(error instanceof UwsUnavailableError)) throw error;
|
|
158
|
+
if (process.env.SPROUTBOAT_BUILD_UWS_FROM_SOURCE !== "1") throw error;
|
|
102
159
|
const haveGit = Bun.which("git");
|
|
103
160
|
const haveMake = Bun.which("make");
|
|
104
161
|
if (haveGit && haveMake) {
|
|
105
162
|
console.warn(
|
|
106
|
-
`prebuilt uWebSockets unusable (${error.message.split("\n")[0]});
|
|
163
|
+
`prebuilt uWebSockets unusable (${error.message.split("\n")[0]}); explicit source build uses git + make`,
|
|
107
164
|
);
|
|
108
165
|
} else {
|
|
109
166
|
const missing = [!haveGit && "git", !haveMake && "make"].filter(Boolean).join(" and ");
|
|
@@ -141,29 +198,24 @@ export async function compileSprout(input: CompileInput): Promise<void> {
|
|
|
141
198
|
),
|
|
142
199
|
);
|
|
143
200
|
|
|
144
|
-
const porffor = porfforRoot();
|
|
145
201
|
const launcher = resolve(porffor, "runtime/index.js");
|
|
146
|
-
// Porffor shells bare `zig` and `esbuild`; put both on PATH for the child.
|
|
147
202
|
// A host build never shells `zig`, so it has no zigBin to contribute.
|
|
148
|
-
const binDir = resolve(porffor, "../.bin");
|
|
149
203
|
const zigDir = input.zigBin ? `${dirname(input.zigBin)}:` : "";
|
|
150
|
-
const
|
|
204
|
+
const packagedEsbuild = resolve(dirname(process.execPath), "esbuild");
|
|
205
|
+
const esbuild = existsSync(packagedEsbuild) ? packagedEsbuild : Bun.which("esbuild");
|
|
206
|
+
if (!esbuild)
|
|
207
|
+
throw new Error("the packaged esbuild executable is missing; reinstall the Sproutboat platform package");
|
|
208
|
+
const path = `${zigDir}${dirname(esbuild)}:${process.env.PATH ?? ""}`;
|
|
209
|
+
const command = PACKAGED
|
|
210
|
+
? [process.execPath, "__porffor", launcher]
|
|
211
|
+
: [process.execPath, resolve(import.meta.dir, "main.ts"), "__porffor", launcher];
|
|
151
212
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
const child = Bun.spawn(
|
|
159
|
-
[process.execPath, launcher, "native", generatedPath, "-o", input.outPath, ...crossFlags, "-s"],
|
|
160
|
-
{
|
|
161
|
-
cwd: outDir,
|
|
162
|
-
stdout: "pipe",
|
|
163
|
-
stderr: "pipe",
|
|
164
|
-
env: compileEnv(path, input.extraLink, input.extraCflags),
|
|
165
|
-
},
|
|
166
|
-
);
|
|
213
|
+
const child = Bun.spawn([...command, ...porfforArgs(generatedPath, input.outPath, input.target, input.optimize)], {
|
|
214
|
+
cwd: outDir,
|
|
215
|
+
stdout: "pipe",
|
|
216
|
+
stderr: "pipe",
|
|
217
|
+
env: compileEnv(path, input.extraLink, input.extraCflags),
|
|
218
|
+
});
|
|
167
219
|
let timedOut = false;
|
|
168
220
|
const timer = setTimeout(() => {
|
|
169
221
|
timedOut = true;
|
package/src/config.ts
CHANGED
|
@@ -1,379 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
*
|
|
5
|
-
* ephemeral local resource for `sproutboat dev`, rejected by a real deploy — or
|
|
6
|
-
* `{ binding, id }` pointing at an account-level resource created with
|
|
7
|
-
* `sproutboat <kv|d1|r2|queues> create`. The id carries its own `<kind>_` prefix.
|
|
8
|
-
*/
|
|
9
|
-
export type ResourceBinding = { binding: string; id: string };
|
|
10
|
-
export type ResourceRef = string | ResourceBinding;
|
|
11
|
-
|
|
12
|
-
/** Normalizes a storage-binding array to `{ binding, id? }` rows. */
|
|
13
|
-
export function resourceRefs(field: readonly ResourceRef[] | undefined): Array<{ binding: string; id?: string }> {
|
|
14
|
-
return (field ?? []).map((entry) =>
|
|
15
|
-
isString(entry) ? { binding: entry } : { binding: entry.binding, id: entry.id },
|
|
16
|
-
);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/**
|
|
20
|
-
* Rewrite a bare `"BINDING"` token inside `<field>: [ … ]` to
|
|
21
|
-
* `{ "binding": "BINDING", "id": "<id>" }`, leaving the rest of the source
|
|
22
|
-
* (comments, spacing) untouched — used by `deploy`'s auto-provisioner to pin an
|
|
23
|
-
* id back into `sproutboat.jsonc`. Binding names are UPPER_SNAKE, so a plain
|
|
24
|
-
* `"BINDING"` match inside the array is unambiguous. No-op if not found.
|
|
25
|
-
*/
|
|
26
|
-
export function pinBindingId(source: string, field: string, binding: string, id: string): string {
|
|
27
|
-
const array = new RegExp(`("${field}"\\s*:\\s*\\[)([\\s\\S]*?)(\\])`);
|
|
28
|
-
// the bare token must be a whole array element — at the start of the array or
|
|
29
|
-
// right after a comma — never `"binding": "NAME"` inside an already-pinned
|
|
30
|
-
// { … } object.
|
|
31
|
-
const element = new RegExp(`(^\\s*|,\\s*)"${binding}"(\\s*,|\\s*$)`);
|
|
32
|
-
return source.replace(array, (whole, open: string, inner: string, close: string) =>
|
|
33
|
-
element.test(inner)
|
|
34
|
-
? open + inner.replace(element, `$1{ "binding": "${binding}", "id": "${id}" }$2`) + close
|
|
35
|
-
: whole,
|
|
36
|
-
);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
export type SproutboatConfig = {
|
|
40
|
-
$schema?: string;
|
|
41
|
-
name: string;
|
|
42
|
-
main: string;
|
|
43
|
-
compatibility_date: string;
|
|
44
|
-
vars?: Record<string, string>;
|
|
45
|
-
/** KV namespace bindings, exposed as `env.<NAME>`. */
|
|
46
|
-
kv_namespaces?: ResourceRef[];
|
|
47
|
-
/** Secret binding names, exposed as `env.<NAME>` (value fetched at use). */
|
|
48
|
-
secrets?: string[];
|
|
49
|
-
/** Hostnames the sprout's `fetch()` may reach (exact host match). */
|
|
50
|
-
outbound?: string[];
|
|
51
|
-
/** D1 (SQLite) database bindings, exposed as `env.<NAME>`. */
|
|
52
|
-
d1_databases?: ResourceRef[];
|
|
53
|
-
/** R2 (object storage) bucket bindings, exposed as `env.<NAME>`. */
|
|
54
|
-
r2_buckets?: ResourceRef[];
|
|
55
|
-
/** Queue producer bindings, exposed as `env.<NAME>.send()`. A `queue(batch)` handler consumes them. */
|
|
56
|
-
queues?: ResourceRef[];
|
|
57
|
-
/** Analytics Engine dataset binding names, exposed as `env.<NAME>.writeDataPoint()`. No id — the dataset is created on first write. */
|
|
58
|
-
analytics_engine_datasets?: string[];
|
|
59
|
-
/** Durable Object bindings: `{ BINDING_NAME: "ClassName" }`. The class is defined in the handler module. */
|
|
60
|
-
durable_objects?: Record<string, string>;
|
|
61
|
-
/** #48 — worker-to-worker calls: `env.<BINDING>.fetch(request)` reaches another
|
|
62
|
-
* project on this control plane, resolved to its hostname at activation. */
|
|
63
|
-
services?: Array<{ binding: string; service: string }>;
|
|
64
|
-
/** Scheduled triggers, e.g. `{ "crons": ["0 3 * * *"] }` — a `scheduled(event)` handler runs on each tick. */
|
|
65
|
-
triggers?: { crons?: string[] };
|
|
66
|
-
/** Static assets: a directory served edge-first (like Cloudflare), optionally bound as `env.<BINDING>.fetch(request)`. */
|
|
67
|
-
assets?: AssetsConfig;
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
export type AssetsConfig = {
|
|
71
|
-
/** Project-relative directory of files to publish with the artifact. */
|
|
72
|
-
directory: string;
|
|
73
|
-
/** Optional binding name for `env.<BINDING>.fetch(request)`. */
|
|
74
|
-
binding?: string;
|
|
75
|
-
/** What to serve when a request matches no file (applied by the broker on `env.<BINDING>.fetch`). */
|
|
76
|
-
not_found_handling?: "none" | "single-page-application" | "404-page";
|
|
77
|
-
/** `true` = run the sprout before serving any asset; string[] = selective route patterns (`!` negates). */
|
|
78
|
-
run_sprout_first?: boolean | string[];
|
|
79
|
-
};
|
|
80
|
-
|
|
81
|
-
export type ConfigValidation = { ok: true; value: SproutboatConfig } | { ok: false; errors: string[] };
|
|
82
|
-
|
|
83
|
-
type JsonValue = string | number | boolean | null | ConfigJsonObject | JsonValue[];
|
|
84
|
-
|
|
85
|
-
interface ConfigJsonObject {
|
|
86
|
-
readonly [key: string]: JsonValue;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
type ConfigInput = JsonValue | undefined;
|
|
90
|
-
|
|
91
|
-
function isRecord(value: ConfigInput): value is ConfigJsonObject {
|
|
92
|
-
return value !== null && Object(value) === value && !Array.isArray(value) && !(value instanceof Function);
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
function isString(value: ConfigInput): value is string {
|
|
96
|
-
return Object(value) !== value && value === String(value);
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function isProjectSlug(value: string): boolean {
|
|
100
|
-
return slugPattern.test(value);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function validateConfig(value: ConfigInput): ConfigValidation {
|
|
104
|
-
const errors: string[] = [];
|
|
105
|
-
if (!isRecord(value)) return { ok: false, errors: ["config must be an object"] };
|
|
106
|
-
const allowed = new Set([
|
|
107
|
-
"$schema",
|
|
108
|
-
"name",
|
|
109
|
-
"main",
|
|
110
|
-
"compatibility_date",
|
|
111
|
-
"vars",
|
|
112
|
-
"kv_namespaces",
|
|
113
|
-
"secrets",
|
|
114
|
-
"outbound",
|
|
115
|
-
"d1_databases",
|
|
116
|
-
"r2_buckets",
|
|
117
|
-
"queues",
|
|
118
|
-
"analytics_engine_datasets",
|
|
119
|
-
"durable_objects",
|
|
120
|
-
"services",
|
|
121
|
-
"triggers",
|
|
122
|
-
"assets",
|
|
123
|
-
]);
|
|
124
|
-
for (const key of Object.keys(value)) if (!allowed.has(key)) errors.push(`unsupported config field: ${key}`);
|
|
125
|
-
const name = isString(value.name) && isProjectSlug(value.name) ? value.name : null;
|
|
126
|
-
if (name === null) {
|
|
127
|
-
errors.push("name must be a 3–32 character lowercase slug");
|
|
128
|
-
}
|
|
129
|
-
const main = isString(value.main) && value.main.startsWith("src/") && !value.main.includes("..") ? value.main : null;
|
|
130
|
-
if (main === null) {
|
|
131
|
-
errors.push("main must be a relative entry point under src/");
|
|
132
|
-
}
|
|
133
|
-
const compatibility_date =
|
|
134
|
-
isString(value.compatibility_date) && /^\d{4}-\d{2}-\d{2}$/.test(value.compatibility_date)
|
|
135
|
-
? value.compatibility_date
|
|
136
|
-
: null;
|
|
137
|
-
if (compatibility_date === null) {
|
|
138
|
-
errors.push("compatibility_date must use YYYY-MM-DD");
|
|
139
|
-
}
|
|
140
|
-
const schema = value.$schema === undefined ? undefined : isString(value.$schema) ? value.$schema : null;
|
|
141
|
-
if (schema === null) errors.push("$schema must be a string");
|
|
142
|
-
let vars: Record<string, string> | undefined;
|
|
143
|
-
if (value.vars !== undefined) {
|
|
144
|
-
if (!isRecord(value.vars)) errors.push("vars must be an object of plain string values");
|
|
145
|
-
else {
|
|
146
|
-
vars = {};
|
|
147
|
-
for (const [key, item] of Object.entries(value.vars)) {
|
|
148
|
-
if (!/^[A-Z][A-Z0-9_]*$/.test(key) || !isString(item))
|
|
149
|
-
errors.push(`vars.${key} must be a string environment name`);
|
|
150
|
-
else vars[key] = item;
|
|
151
|
-
}
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
const bindingName = /^[A-Z][A-Z0-9_]*$/;
|
|
155
|
-
const hostPattern = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/;
|
|
156
|
-
const stringArray = (
|
|
157
|
-
field: "secrets" | "outbound" | "analytics_engine_datasets",
|
|
158
|
-
item: RegExp,
|
|
159
|
-
label: string,
|
|
160
|
-
): string[] | undefined => {
|
|
161
|
-
if (value[field] === undefined) return undefined;
|
|
162
|
-
const raw = value[field];
|
|
163
|
-
if (!Array.isArray(raw)) {
|
|
164
|
-
errors.push(`${field} must be an array of ${label}`);
|
|
165
|
-
return undefined;
|
|
166
|
-
}
|
|
167
|
-
const out: string[] = [];
|
|
168
|
-
for (const entry of raw) {
|
|
169
|
-
if (!isString(entry) || !item.test(entry)) errors.push(`${field} entries must be ${label}`);
|
|
170
|
-
else out.push(entry);
|
|
171
|
-
}
|
|
172
|
-
return out;
|
|
173
|
-
};
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* A storage-binding array (#74): each entry is a bare `"BINDING"` or
|
|
177
|
-
* `{ binding: "BINDING", id: "<kind>_<24hex>" }`. `kind` is the field's own
|
|
178
|
-
* resource kind, so an r2 id can't be pasted into `kv_namespaces`.
|
|
179
|
-
*/
|
|
180
|
-
const resourceArray = (
|
|
181
|
-
field: "kv_namespaces" | "d1_databases" | "r2_buckets" | "queues",
|
|
182
|
-
kind: string,
|
|
183
|
-
): ResourceRef[] | undefined => {
|
|
184
|
-
if (value[field] === undefined) return undefined;
|
|
185
|
-
const raw = value[field];
|
|
186
|
-
if (!Array.isArray(raw)) {
|
|
187
|
-
errors.push(`${field} must be an array of binding names or { binding, id } objects`);
|
|
188
|
-
return undefined;
|
|
189
|
-
}
|
|
190
|
-
const idPattern = new RegExp(`^${kind}_[0-9a-f]{24}$`);
|
|
191
|
-
const out: ResourceRef[] = [];
|
|
192
|
-
for (const entry of raw) {
|
|
193
|
-
if (isString(entry)) {
|
|
194
|
-
if (bindingName.test(entry)) out.push(entry);
|
|
195
|
-
else errors.push(`${field}: "${entry}" must be an UPPER_SNAKE binding name`);
|
|
196
|
-
} else if (
|
|
197
|
-
isRecord(entry) &&
|
|
198
|
-
isString(entry.binding) &&
|
|
199
|
-
isString(entry.id) &&
|
|
200
|
-
bindingName.test(entry.binding) &&
|
|
201
|
-
idPattern.test(entry.id) &&
|
|
202
|
-
Object.keys(entry).every((key) => key === "binding" || key === "id")
|
|
203
|
-
) {
|
|
204
|
-
out.push({ binding: entry.binding, id: entry.id });
|
|
205
|
-
} else {
|
|
206
|
-
errors.push(`${field} entries must be an UPPER_SNAKE name or { binding: "NAME", id: "${kind}_…" }`);
|
|
207
|
-
}
|
|
208
|
-
}
|
|
209
|
-
return out;
|
|
210
|
-
};
|
|
211
|
-
|
|
212
|
-
const secrets = stringArray("secrets", bindingName, "binding names (UPPER_SNAKE_CASE)");
|
|
213
|
-
const outbound = stringArray("outbound", hostPattern, "hostnames");
|
|
214
|
-
// Analytics Engine datasets aren't provisioned — the dataset name springs into
|
|
215
|
-
// existence on first writeDataPoint(), so there's no resource id to bind (#74).
|
|
216
|
-
const analytics_engine_datasets = stringArray(
|
|
217
|
-
"analytics_engine_datasets",
|
|
218
|
-
bindingName,
|
|
219
|
-
"binding names (UPPER_SNAKE_CASE)",
|
|
220
|
-
);
|
|
221
|
-
const kv_namespaces = resourceArray("kv_namespaces", "kv");
|
|
222
|
-
const d1_databases = resourceArray("d1_databases", "d1");
|
|
223
|
-
const r2_buckets = resourceArray("r2_buckets", "r2");
|
|
224
|
-
const queues = resourceArray("queues", "queue");
|
|
225
|
-
|
|
226
|
-
let durable_objects: Record<string, string> | undefined;
|
|
227
|
-
if (value.durable_objects !== undefined) {
|
|
228
|
-
if (!isRecord(value.durable_objects)) {
|
|
229
|
-
errors.push('durable_objects must be an object of { BINDING_NAME: "ClassName" }');
|
|
230
|
-
} else {
|
|
231
|
-
durable_objects = {};
|
|
232
|
-
for (const [binding, className] of Object.entries(value.durable_objects)) {
|
|
233
|
-
if (!bindingName.test(binding) || !isString(className) || !/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(className)) {
|
|
234
|
-
errors.push(`durable_objects.${binding} must map an UPPER_SNAKE binding to a class identifier`);
|
|
235
|
-
} else durable_objects[binding] = className;
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
let services: Array<{ binding: string; service: string }> | undefined;
|
|
241
|
-
if (value.services !== undefined) {
|
|
242
|
-
if (!Array.isArray(value.services)) {
|
|
243
|
-
errors.push('services must be an array of { binding: "NAME", service: "project-name" }');
|
|
244
|
-
} else {
|
|
245
|
-
services = [];
|
|
246
|
-
for (const entry of value.services) {
|
|
247
|
-
const row = isRecord(entry) ? entry : null;
|
|
248
|
-
const binding = row && isString(row.binding) ? row.binding : "";
|
|
249
|
-
const service = row && isString(row.service) ? row.service : "";
|
|
250
|
-
if (!bindingName.test(binding) || !isProjectSlug(service)) {
|
|
251
|
-
errors.push('services entries must be { binding: "UPPER_SNAKE", service: "project-name" }');
|
|
252
|
-
} else if (services.some((s) => s.binding === binding)) {
|
|
253
|
-
errors.push(`services: duplicate binding ${binding}`);
|
|
254
|
-
} else services.push({ binding, service });
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
let triggers: { crons?: string[] } | undefined;
|
|
260
|
-
if (value.triggers !== undefined) {
|
|
261
|
-
if (!isRecord(value.triggers)) {
|
|
262
|
-
errors.push("triggers must be an object with an optional `crons` array");
|
|
263
|
-
} else {
|
|
264
|
-
triggers = {};
|
|
265
|
-
if (value.triggers.crons !== undefined) {
|
|
266
|
-
const raw = value.triggers.crons;
|
|
267
|
-
if (!Array.isArray(raw) || raw.some((c) => !isString(c) || c.trim().split(/\s+/).length !== 5)) {
|
|
268
|
-
errors.push("triggers.crons must be an array of 5-field cron expressions");
|
|
269
|
-
} else triggers.crons = raw.map((c) => String(c).trim());
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
let assets: AssetsConfig | undefined;
|
|
275
|
-
if (value.assets !== undefined) {
|
|
276
|
-
if (!isRecord(value.assets)) {
|
|
277
|
-
errors.push("assets must be an object with a `directory`");
|
|
278
|
-
} else {
|
|
279
|
-
const raw = value.assets;
|
|
280
|
-
const dir =
|
|
281
|
-
isString(raw.directory) && raw.directory.length > 0 && !raw.directory.includes("..")
|
|
282
|
-
? raw.directory.replace(/^\.\//, "").replace(/\/$/, "")
|
|
283
|
-
: null;
|
|
284
|
-
if (dir === null) errors.push("assets.directory must be a project-relative path");
|
|
285
|
-
const binding =
|
|
286
|
-
raw.binding === undefined
|
|
287
|
-
? undefined
|
|
288
|
-
: isString(raw.binding) && bindingName.test(raw.binding)
|
|
289
|
-
? raw.binding
|
|
290
|
-
: null;
|
|
291
|
-
if (binding === null) errors.push("assets.binding must be a binding name (UPPER_SNAKE_CASE)");
|
|
292
|
-
const nfh =
|
|
293
|
-
raw.not_found_handling === "none" ||
|
|
294
|
-
raw.not_found_handling === "single-page-application" ||
|
|
295
|
-
raw.not_found_handling === "404-page"
|
|
296
|
-
? raw.not_found_handling
|
|
297
|
-
: undefined;
|
|
298
|
-
if (raw.not_found_handling !== undefined && nfh === undefined) {
|
|
299
|
-
errors.push('assets.not_found_handling must be "none", "single-page-application", or "404-page"');
|
|
300
|
-
}
|
|
301
|
-
let rwf: boolean | string[] | undefined;
|
|
302
|
-
const rwfRaw = raw.run_sprout_first;
|
|
303
|
-
if (rwfRaw === true || rwfRaw === false) rwf = rwfRaw;
|
|
304
|
-
else if (Array.isArray(rwfRaw) && rwfRaw.every((p) => isString(p) && /^!?\//.test(p))) {
|
|
305
|
-
rwf = rwfRaw.map((p) => String(p));
|
|
306
|
-
} else if (rwfRaw !== undefined) {
|
|
307
|
-
errors.push("assets.run_sprout_first must be a boolean or an array of route patterns");
|
|
308
|
-
}
|
|
309
|
-
if (dir !== null && binding !== null) {
|
|
310
|
-
assets = { directory: dir };
|
|
311
|
-
if (binding !== undefined) assets.binding = binding;
|
|
312
|
-
if (nfh !== undefined) assets.not_found_handling = nfh;
|
|
313
|
-
if (rwf !== undefined) assets.run_sprout_first = rwf;
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
const resourceNames = (refs: ResourceRef[] | undefined): string[] => resourceRefs(refs).map((ref) => ref.binding);
|
|
319
|
-
const bindingSlots = [
|
|
320
|
-
...resourceNames(kv_namespaces),
|
|
321
|
-
...(secrets ?? []),
|
|
322
|
-
...resourceNames(d1_databases),
|
|
323
|
-
...resourceNames(r2_buckets),
|
|
324
|
-
...resourceNames(queues),
|
|
325
|
-
...(analytics_engine_datasets ?? []),
|
|
326
|
-
...Object.keys(durable_objects ?? {}),
|
|
327
|
-
...(services ?? []).map((entry) => entry.binding),
|
|
328
|
-
...Object.keys(vars ?? {}),
|
|
329
|
-
...(assets?.binding ? [assets.binding] : []),
|
|
330
|
-
];
|
|
331
|
-
if (new Set(bindingSlots).size !== bindingSlots.length) errors.push("vars and binding names must not collide");
|
|
332
|
-
|
|
333
|
-
if (errors.length || name === null || main === null || compatibility_date === null || schema === null)
|
|
334
|
-
return { ok: false, errors };
|
|
335
|
-
const config: SproutboatConfig = { name, main, compatibility_date };
|
|
336
|
-
if ("$schema" in value) config.$schema = schema;
|
|
337
|
-
if ("vars" in value) config.vars = vars;
|
|
338
|
-
if ("kv_namespaces" in value) config.kv_namespaces = kv_namespaces;
|
|
339
|
-
if ("secrets" in value) config.secrets = secrets;
|
|
340
|
-
if ("outbound" in value) config.outbound = outbound;
|
|
341
|
-
if ("d1_databases" in value) config.d1_databases = d1_databases;
|
|
342
|
-
if ("r2_buckets" in value) config.r2_buckets = r2_buckets;
|
|
343
|
-
if ("queues" in value) config.queues = queues;
|
|
344
|
-
if ("analytics_engine_datasets" in value) config.analytics_engine_datasets = analytics_engine_datasets;
|
|
345
|
-
if ("durable_objects" in value) config.durable_objects = durable_objects;
|
|
346
|
-
if ("services" in value) config.services = services;
|
|
347
|
-
if ("triggers" in value) config.triggers = triggers;
|
|
348
|
-
if ("assets" in value) config.assets = assets;
|
|
349
|
-
return { ok: true, value: config };
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
export function parseConfig(source: string): ConfigValidation {
|
|
353
|
-
try {
|
|
354
|
-
// Config files intentionally support comments and trailing commas, but not
|
|
355
|
-
// arbitrary JavaScript expressions.
|
|
356
|
-
let json = "";
|
|
357
|
-
let quoted = false;
|
|
358
|
-
for (let index = 0; index < source.length; index++) {
|
|
359
|
-
const character = source[index];
|
|
360
|
-
if (character === '"' && source[index - 1] !== "\\") quoted = !quoted;
|
|
361
|
-
if (!quoted && character === "/" && source[index + 1] === "/") {
|
|
362
|
-
index = source.indexOf("\n", index);
|
|
363
|
-
if (index < 0) break;
|
|
364
|
-
json += "\n";
|
|
365
|
-
} else if (!quoted && character === "/" && source[index + 1] === "*") {
|
|
366
|
-
index = source.indexOf("*/", index + 2);
|
|
367
|
-
if (index < 0) throw new SyntaxError("unterminated block comment");
|
|
368
|
-
index++;
|
|
369
|
-
} else json += character;
|
|
370
|
-
}
|
|
371
|
-
json = json.replace(/,\s*([}\]])/g, "$1");
|
|
372
|
-
return validateConfig(JSON.parse(json));
|
|
373
|
-
} catch (error) {
|
|
374
|
-
return {
|
|
375
|
-
ok: false,
|
|
376
|
-
errors: [`invalid sproutboat.jsonc: ${error instanceof Error ? error.message : String(error)}`],
|
|
377
|
-
};
|
|
378
|
-
}
|
|
379
|
-
}
|
|
1
|
+
// Re-exported from @sproutboat/config (moved verbatim). This shim keeps
|
|
2
|
+
// every `./config` importer and the `sproutboat/runtime/config` export path
|
|
3
|
+
// working; the implementation lives in the shared package.
|
|
4
|
+
export * from "@sproutboat/config";
|