sproutboat 0.9.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 +62 -1
- package/README.md +20 -7
- package/SURFACE.md +5 -1
- package/bin/sproutboat.cjs +32 -0
- package/package.json +19 -6
- package/scripts/build-cli.ts +44 -0
- package/src/assets.ts +4 -121
- package/src/broker.ts +4 -1182
- package/src/build.ts +24 -17
- package/src/compile.ts +40 -19
- package/src/config.ts +4 -379
- package/src/dev.ts +263 -62
- package/src/json.ts +3 -38
- package/src/main.ts +39 -14
- 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 +16 -0
- package/src/toolchain.ts +157 -44
- package/src/types-gen.ts +3 -0
- package/src/wrap.ts +5 -267
- package/types/sproutboat.d.ts +31 -0
- package/src/native-fetch-prelude.js +0 -950
- package/src/transport-broker.js +0 -299
- package/src/transport-embedded.js +0 -1571
package/src/build.ts
CHANGED
|
@@ -42,6 +42,10 @@ export type BuildInput = {
|
|
|
42
42
|
* for the fast path deliberately.
|
|
43
43
|
*/
|
|
44
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;
|
|
45
49
|
};
|
|
46
50
|
|
|
47
51
|
export type BuildOutput = {
|
|
@@ -67,7 +71,7 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
|
|
|
67
71
|
const source = input.source ?? (await readFile(input.sourcePath));
|
|
68
72
|
const sourceHash = digest(source);
|
|
69
73
|
const artifactId = sourceHash.slice("sha256:".length, 24);
|
|
70
|
-
const artifactDir = resolve(input.projectDir, ".sproutboat/dist", artifactId);
|
|
74
|
+
const artifactDir = input.outputDirectory ?? resolve(input.projectDir, ".sproutboat/dist", artifactId);
|
|
71
75
|
const sproutPath = resolve(artifactDir, "sprout");
|
|
72
76
|
await mkdir(artifactDir, { recursive: true });
|
|
73
77
|
|
|
@@ -95,6 +99,7 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
|
|
|
95
99
|
do: Object.entries(input.config.durable_objects ?? {}).map(([binding, className]) => ({ binding, className })),
|
|
96
100
|
services: input.config.services ?? [],
|
|
97
101
|
crons: input.config.triggers?.crons ?? [],
|
|
102
|
+
ratelimiters: input.config.ratelimiters ?? [],
|
|
98
103
|
assets: input.config.assets?.binding ?? "",
|
|
99
104
|
// Baked plain values, read as env.NAME. The broker never serves these (they
|
|
100
105
|
// are compiled in via SPROUTBOAT_VARS_JSON); they ride along so the control
|
|
@@ -145,22 +150,24 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
|
|
|
145
150
|
bakedAssets = { manifest, files };
|
|
146
151
|
}
|
|
147
152
|
|
|
148
|
-
await
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
+
});
|
|
164
171
|
|
|
165
172
|
const sprout = await readFile(sproutPath);
|
|
166
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, ensureUWebSocketsHost,
|
|
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 {
|
|
@@ -83,9 +97,13 @@ export type CompileInput = {
|
|
|
83
97
|
* `native-fetch-prelude.js` alone yields a module with no `__sbCall` at all.
|
|
84
98
|
*/
|
|
85
99
|
export async function loadPrelude(transport: Transport = "broker"): Promise<string> {
|
|
86
|
-
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")]);
|
|
87
103
|
if (!core.includes(TRANSPORT_MARKER)) {
|
|
88
|
-
throw new Error(
|
|
104
|
+
throw new Error(
|
|
105
|
+
"prelude is missing its transport marker — @sproutboat/runtime/src/native-fetch-prelude.js changed shape",
|
|
106
|
+
);
|
|
89
107
|
}
|
|
90
108
|
return core.replace(TRANSPORT_MARKER, chosen);
|
|
91
109
|
}
|
|
@@ -125,7 +143,8 @@ export function porfforArgs(
|
|
|
125
143
|
|
|
126
144
|
/** Compile `sourcePath` to a native binary at `outPath` (mode 0555). */
|
|
127
145
|
export async function compileSprout(input: CompileInput): Promise<void> {
|
|
128
|
-
await
|
|
146
|
+
const porffor = await ensurePorffor();
|
|
147
|
+
await ensurePorfforPatched(porffor);
|
|
129
148
|
|
|
130
149
|
// Seed the Porffor uWebSockets cache from the prebuilt archive in `vendor/` so
|
|
131
150
|
// the first build needs no `git` / `make`. The two targets keep separate
|
|
@@ -136,11 +155,12 @@ export async function compileSprout(input: CompileInput): Promise<void> {
|
|
|
136
155
|
else await ensureUWebSockets();
|
|
137
156
|
} catch (error) {
|
|
138
157
|
if (!(error instanceof UwsUnavailableError)) throw error;
|
|
158
|
+
if (process.env.SPROUTBOAT_BUILD_UWS_FROM_SOURCE !== "1") throw error;
|
|
139
159
|
const haveGit = Bun.which("git");
|
|
140
160
|
const haveMake = Bun.which("make");
|
|
141
161
|
if (haveGit && haveMake) {
|
|
142
162
|
console.warn(
|
|
143
|
-
`prebuilt uWebSockets unusable (${error.message.split("\n")[0]});
|
|
163
|
+
`prebuilt uWebSockets unusable (${error.message.split("\n")[0]}); explicit source build uses git + make`,
|
|
144
164
|
);
|
|
145
165
|
} else {
|
|
146
166
|
const missing = [!haveGit && "git", !haveMake && "make"].filter(Boolean).join(" and ");
|
|
@@ -178,23 +198,24 @@ export async function compileSprout(input: CompileInput): Promise<void> {
|
|
|
178
198
|
),
|
|
179
199
|
);
|
|
180
200
|
|
|
181
|
-
const porffor = porfforRoot();
|
|
182
201
|
const launcher = resolve(porffor, "runtime/index.js");
|
|
183
|
-
// Porffor shells bare `zig` and `esbuild`; put both on PATH for the child.
|
|
184
202
|
// A host build never shells `zig`, so it has no zigBin to contribute.
|
|
185
|
-
const binDir = resolve(porffor, "../.bin");
|
|
186
203
|
const zigDir = input.zigBin ? `${dirname(input.zigBin)}:` : "";
|
|
187
|
-
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];
|
|
188
212
|
|
|
189
|
-
const child = Bun.spawn(
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
env: compileEnv(path, input.extraLink, input.extraCflags),
|
|
196
|
-
},
|
|
197
|
-
);
|
|
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
|
+
});
|
|
198
219
|
let timedOut = false;
|
|
199
220
|
const timer = setTimeout(() => {
|
|
200
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";
|