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/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 compileSprout({
149
- sourcePath: input.sourcePath,
150
- source: input.source,
151
- outPath: sproutPath,
152
- vars: input.config.vars ?? {},
153
- bindings,
154
- zigBin,
155
- target: input.target,
156
- compatibilityDate: input.config.compatibility_date,
157
- transport: input.transport,
158
- appName: input.config.name,
159
- assets: bakedAssets,
160
- extraLink,
161
- extraCflags,
162
- optimize: input.optimize,
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 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, ensureUWebSocketsHost, 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 {
@@ -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] = 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")]);
87
103
  if (!core.includes(TRANSPORT_MARKER)) {
88
- 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
+ );
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 ensurePorfforPatched();
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]}); falling back to git + make (slower, one-time)`,
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 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];
188
212
 
189
- const child = Bun.spawn(
190
- [process.execPath, launcher, ...porfforArgs(generatedPath, input.outPath, input.target, input.optimize)],
191
- {
192
- cwd: outDir,
193
- stdout: "pipe",
194
- stderr: "pipe",
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
- 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";