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/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 compileSprout({
142
- sourcePath: input.sourcePath,
143
- source: input.source,
144
- outPath: sproutPath,
145
- vars: input.config.vars ?? {},
146
- bindings,
147
- zigBin,
148
- target: input.target,
149
- compatibilityDate: input.config.compatibility_date,
150
- transport: input.transport,
151
- appName: input.config.name,
152
- assets: bakedAssets,
153
- extraLink,
154
- extraCflags,
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 falls back to Porffor's own git + make path (needs both on PATH).
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, porfforRoot, UwsUnavailableError } from "./toolchain";
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] = await Promise.all([readFile(preludePath, "utf8"), readFile(transportPath(transport), "utf8")]);
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("prelude is missing its transport marker — src/native-fetch-prelude.js changed shape");
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 ensurePorfforPatched();
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`. Fall back to Porffor's own git+make
97
- // path if the archive is missing or fails its checksum.
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 ensureUWebSockets();
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]}); falling back to git + make (slower, one-time)`,
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 path = `${zigDir}${binDir}:${process.env.PATH ?? ""}`;
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
- // `-s`: strip at link. The unstripped static-musl binary is ~90% DWARF that
153
- // nothing needs at runtime (12 MB -> ~1.3 MB for the kitchen-sink). Porffor
154
- // forwards `-s` straight to the `zig cc` link step.
155
- // `--musl` is what makes it a cross-compile; a host build simply omits it and
156
- // Porffor targets the machine it is running on.
157
- const crossFlags = input.target === "host" ? [] : ["--musl"];
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
- const slugPattern = /^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])?$/;
2
-
3
- /**
4
- * A storage binding entry (#74). Either a bare `"BINDING"` — resolved to an
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";