sproutboat 0.54.0 → 0.55.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.
@@ -0,0 +1,227 @@
1
+ /**
2
+ * `sproutboat.jsonc` bindings as the Sproutboat Cloud's app record carries them (sproutboat-cloud `src/record.ts`,
3
+ * `bindings`, `crons`, `queue_consumers`). The Cloud validates the same shape again on `PUT /v1/apps/<app>` and
4
+ * `POST /v1/apps/<app>/builds`; checking here means a mistake fails before anything is uploaded, in the CLI's words.
5
+ *
6
+ * Where a resource id comes from: on the Cloud each app's storage is private to it, so the binding is the name
7
+ * (`d1_databases: ["DB"]` is `d1/DB`, the name `d1 execute --remote` and `r2 object` address). A binding that needs a
8
+ * `resource_id` (kv, d1, r2, queue) gets its own name in lowercase, because the record's resource ids are lowercase;
9
+ * a queue gets its queue name. Nothing is provisioned: the host creates the storage on first use.
10
+ */
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { resolve } from "node:path";
13
+ import { queueSettings, resourceRefs, type SproutboatConfig } from "./config";
14
+ import { CloudError } from "./cloud";
15
+
16
+ export type RecordBinding = {
17
+ name: string;
18
+ kind: "kv" | "d1" | "r2" | "queue" | "do" | "ae" | "ratelimit" | "assets" | "secret";
19
+ resource_id: string | null;
20
+ config: Record<string, string | number>;
21
+ };
22
+ export type RecordQueueConsumer = {
23
+ queue: string;
24
+ max_batch_size: number;
25
+ max_batch_timeout_ms: number;
26
+ max_retries: number;
27
+ dead_letter_queue: string | null;
28
+ };
29
+ export type CloudBindings = {
30
+ bindings: RecordBinding[];
31
+ crons: string[];
32
+ queue_consumers: RecordQueueConsumer[];
33
+ /** Things the Cloud ignores, said once before the deploy. */
34
+ warnings: string[];
35
+ };
36
+
37
+ /** The record's own limits (sproutboat-cloud `src/record.ts`). */
38
+ export const MAX_BINDINGS = 64;
39
+ export const MAX_CRONS = 16;
40
+ export const MAX_QUEUE_CONSUMERS = 16;
41
+ const RESOURCE_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/;
42
+ const CRON_CHARS_RE = /^[0-9A-Za-z*,/ -]+$/;
43
+
44
+ /** Cloudflare's defaults for a queue consumer, which the runtime applies when sproutboat.jsonc leaves a setting out. */
45
+ const QUEUE_DEFAULTS = { maxBatchSize: 10, maxBatchTimeout: 0, maxRetries: 3 };
46
+
47
+ /** `resource_id` for a binding or queue name: lowercase, so `DB` is `db` and `EMAILS` is `emails`. */
48
+ function resourceIdFor(what: string, name: string): string {
49
+ const id = name.toLowerCase();
50
+ if (!RESOURCE_ID_RE.test(id))
51
+ throw new CloudError(
52
+ `${what} "${name}" cannot be a Cloud resource name: lowercased it must be 1 to 64 characters, start with a letter or digit, and use only letters, digits, "_" and "-"`,
53
+ );
54
+ return id;
55
+ }
56
+
57
+ /** A cron as the Cloud stores it: single spaces, five fields, at most 128 characters of the allowed set. */
58
+ function normalizeCron(expression: string): string {
59
+ const cron = expression.trim().split(/\s+/).join(" ");
60
+ if (cron.length > 128 || !CRON_CHARS_RE.test(cron) || cron.split(" ").length !== 5)
61
+ throw new CloudError(
62
+ `triggers.crons: "${expression}" is not a cron the Cloud accepts (5 fields of digits, letters and * , / -, at most 128 characters)`,
63
+ );
64
+ return cron;
65
+ }
66
+
67
+ type Mappable = Pick<
68
+ SproutboatConfig,
69
+ | "kv_namespaces"
70
+ | "d1_databases"
71
+ | "r2_buckets"
72
+ | "queues"
73
+ | "analytics_engine_datasets"
74
+ | "durable_objects"
75
+ | "ratelimiters"
76
+ | "secrets"
77
+ | "assets"
78
+ | "triggers"
79
+ >;
80
+
81
+ /**
82
+ * Every binding kind in sproutboat.jsonc as a record entry, plus `crons` and one `queue_consumers` entry per queue
83
+ * (the worker consumes every queue it binds). `serviceBindings` is how many service bindings the deploy adds, so the
84
+ * 64-binding limit counts them. Throws CloudError for anything the Cloud would refuse.
85
+ */
86
+ export function mapCloudBindings(config: Mappable, serviceBindings = 0): CloudBindings {
87
+ const bindings: RecordBinding[] = [];
88
+ const warnings: string[] = [];
89
+ const pinned: string[] = [];
90
+
91
+ for (const [kind, field] of [
92
+ ["kv", "kv_namespaces"],
93
+ ["d1", "d1_databases"],
94
+ ["r2", "r2_buckets"],
95
+ ] as const)
96
+ for (const ref of resourceRefs(config[field])) {
97
+ if (ref.id !== undefined) pinned.push(`${field}.${ref.binding}`);
98
+ bindings.push({
99
+ name: ref.binding,
100
+ kind,
101
+ resource_id: resourceIdFor(`${field} binding`, ref.binding),
102
+ config: {},
103
+ });
104
+ }
105
+
106
+ const queue_consumers: RecordQueueConsumer[] = [];
107
+ const queueNames = new Set<string>();
108
+ for (const queue of queueSettings(config.queues)) {
109
+ const resource = resourceIdFor("queue", queue.queue);
110
+ if (queueNames.has(resource))
111
+ throw new CloudError(
112
+ `queues: two bindings name the queue "${resource}" (queue names are lowercase on the Cloud)`,
113
+ );
114
+ queueNames.add(resource);
115
+ const settings: RecordBinding["config"] = { queue: queue.queue };
116
+ if (queue.retryDelay !== undefined) settings.retry_delay = queue.retryDelay;
117
+ bindings.push({ name: queue.binding, kind: "queue", resource_id: resource, config: settings });
118
+ queue_consumers.push({
119
+ queue: resource,
120
+ max_batch_size: queue.maxBatchSize ?? QUEUE_DEFAULTS.maxBatchSize,
121
+ max_batch_timeout_ms: Math.round((queue.maxBatchTimeout ?? QUEUE_DEFAULTS.maxBatchTimeout) * 1000),
122
+ max_retries: queue.maxRetries ?? QUEUE_DEFAULTS.maxRetries,
123
+ dead_letter_queue:
124
+ queue.deadLetterQueue === undefined ? null : resourceIdFor("dead_letter_queue", queue.deadLetterQueue),
125
+ });
126
+ }
127
+ for (const ref of resourceRefs(config.queues)) if (ref.id !== undefined) pinned.push(`queues.${ref.binding}`);
128
+
129
+ for (const name of config.analytics_engine_datasets ?? [])
130
+ bindings.push({ name, kind: "ae", resource_id: null, config: {} });
131
+ for (const [name, className] of Object.entries(config.durable_objects ?? {}))
132
+ bindings.push({ name, kind: "do", resource_id: null, config: { class_name: className } });
133
+ for (const limiter of config.ratelimiters ?? [])
134
+ bindings.push({
135
+ name: limiter.binding,
136
+ kind: "ratelimit",
137
+ resource_id: null,
138
+ config: { limit: limiter.limit, period: limiter.period },
139
+ });
140
+ if (config.assets?.binding)
141
+ bindings.push({ name: config.assets.binding, kind: "assets", resource_id: null, config: {} });
142
+ for (const name of config.secrets ?? []) bindings.push({ name, kind: "secret", resource_id: null, config: {} });
143
+
144
+ if (bindings.length + serviceBindings > MAX_BINDINGS)
145
+ throw new CloudError(
146
+ `sproutboat.jsonc declares ${bindings.length + serviceBindings} bindings (secrets, services and assets count); the Cloud takes at most ${MAX_BINDINGS} per app`,
147
+ );
148
+ if (queue_consumers.length > MAX_QUEUE_CONSUMERS)
149
+ throw new CloudError(
150
+ `sproutboat.jsonc binds ${queue_consumers.length} queues, and each one is consumed by the app; the Cloud takes at most ${MAX_QUEUE_CONSUMERS} queue consumers per app`,
151
+ );
152
+ const crons = (config.triggers?.crons ?? []).map(normalizeCron);
153
+ if (crons.length > MAX_CRONS)
154
+ throw new CloudError(`triggers.crons lists ${crons.length} crons; the Cloud takes at most ${MAX_CRONS} per app`);
155
+
156
+ if (pinned.length > 0)
157
+ warnings.push(
158
+ `${pinned.join(", ")} pin${pinned.length === 1 ? "s" : ""} an account-level id; on the Cloud each app's storage is private to it, so the id is ignored and the app gets its own`,
159
+ );
160
+ return { bindings, crons, queue_consumers, warnings };
161
+ }
162
+
163
+ /** The names a module exports, as far as its source says. `null` when an `export *` hides some. */
164
+ function exportedNames(source: string): Set<string> | null {
165
+ const code = source
166
+ .replace(/\/\*[\s\S]*?\*\//g, "")
167
+ .replace(/(^|[^:])\/\/.*$/gm, "$1")
168
+ .replace(/"(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*'/g, '""');
169
+ if (/\bexport\s*\*\s*(?!as\b)from\b/.test(code)) return null;
170
+ const names = new Set<string>();
171
+ for (const m of code.matchAll(
172
+ /\bexport\s+(?:default\s+)?(?:async\s+)?(?:class|function\*?|const|let|var)\s+([A-Za-z_$][\w$]*)/g,
173
+ ))
174
+ names.add(m[1]!);
175
+ for (const m of code.matchAll(/\bexport\s*(?:type\s*)?\{([^}]*)\}/g))
176
+ for (const part of m[1]!.split(",")) {
177
+ const exported = part
178
+ .trim()
179
+ .split(/\s+as\s+/)
180
+ .at(-1)
181
+ ?.trim();
182
+ if (exported) names.add(exported);
183
+ }
184
+ for (const m of code.matchAll(/\bexport\s*\*\s*as\s+([A-Za-z_$][\w$]*)\s+from\b/g)) names.add(m[1]!);
185
+ return names;
186
+ }
187
+
188
+ /**
189
+ * Every class named in `durable_objects` must be exported by the entry module, or the app starts and then fails at the
190
+ * first Durable Object call. Checked from the source text: a source with an `export *` cannot be judged and passes, and so
191
+ * does an entry the CLI cannot read (the build reports that itself).
192
+ */
193
+ export function checkDurableObjectExports(config: Mappable & Pick<SproutboatConfig, "main">, directory: string): void {
194
+ const classes = Object.entries(config.durable_objects ?? {});
195
+ if (classes.length === 0) return;
196
+ const entry = resolve(directory, config.main);
197
+ if (!existsSync(entry)) return;
198
+ const exported = exportedNames(readFileSync(entry, "utf8"));
199
+ if (exported === null) return;
200
+ for (const [binding, className] of classes)
201
+ if (!exported.has(className))
202
+ throw new CloudError(
203
+ `durable_objects ${binding} names the class "${className}", which ${config.main} does not export; add \`export class ${className}\` (or re-export it) so the Cloud can run it`,
204
+ );
205
+ }
206
+
207
+ /** The plan as lines, for `deploy` and `--dry-run`: what each binding becomes on the Cloud. */
208
+ export function describeCloudBindings(plan: CloudBindings): string[] {
209
+ const lines: string[] = [];
210
+ for (const b of plan.bindings) {
211
+ const target =
212
+ b.kind === "do"
213
+ ? `class ${String(b.config.class_name)}`
214
+ : b.kind === "ratelimit"
215
+ ? `${String(b.config.limit)} per ${String(b.config.period)} s`
216
+ : b.resource_id !== null
217
+ ? `-> ${b.resource_id}`
218
+ : "";
219
+ lines.push(`${b.kind.padEnd(9)} ${b.name}${target ? ` ${target}` : ""}`);
220
+ }
221
+ for (const c of plan.queue_consumers)
222
+ lines.push(
223
+ `consumer ${c.queue} batch ${c.max_batch_size} / ${c.max_batch_timeout_ms} ms, ${c.max_retries} retries${c.dead_letter_queue ? `, dead letters to ${c.dead_letter_queue}` : ""}`,
224
+ );
225
+ for (const cron of plan.crons) lines.push(`cron ${cron}`);
226
+ return lines;
227
+ }
@@ -7,6 +7,7 @@ import { existsSync, readFileSync } from "node:fs";
7
7
  import { resolve } from "node:path";
8
8
  import { readAssetRules, walkAssets, type AssetManifest } from "./assets";
9
9
  import type { SproutboatConfig } from "./config";
10
+ import { MAX_BINDINGS, type CloudBindings, type RecordBinding, type RecordQueueConsumer } from "./cloud-bindings";
10
11
  import { authHeaders, CloudError, explainApiFailure, parseBody, type CloudIo } from "./cloud";
11
12
  import { isSafeInteger, isString, jsonObject, type JsonObject, type JsonValue } from "./json";
12
13
  import { dim, ok } from "./style";
@@ -87,6 +88,8 @@ export type ServiceWiring = { bindings: ServiceBindingEntry[]; services: Record<
87
88
  export type SpecExtras = {
88
89
  /** Service bindings from sproutboat.jsonc (`services`), resolved against the team's Cloud apps before anything is written. */
89
90
  services?: ServiceRef[];
91
+ /** Every other binding in sproutboat.jsonc, mapped by `mapCloudBindings`: sent as `bindings`, `crons` and `queue_consumers`. */
92
+ cloudBindings?: CloudBindings;
90
93
  compatibilityDate?: string;
91
94
  /**
92
95
  * Merged into the app's secrets, never replacing the set. A module deploy sends them as `secrets_patch.set` in its PUT;
@@ -139,11 +142,15 @@ function applyExtras(
139
142
  spec: AppSpec | BuildSpec,
140
143
  wiring: ServiceWiring | undefined,
141
144
  secrets: Record<string, string> | undefined,
145
+ mapped: CloudBindings | undefined,
142
146
  ) {
143
- if (wiring) {
144
- spec.bindings = wiring.bindings;
145
- spec.services = wiring.services;
146
- }
147
+ const bindings: (RecordBinding | ServiceBindingEntry)[] = [...(mapped?.bindings ?? []), ...(wiring?.bindings ?? [])];
148
+ if (bindings.length > MAX_BINDINGS)
149
+ throw new CloudError(`the app has ${bindings.length} bindings; the Cloud takes at most ${MAX_BINDINGS}`);
150
+ if (bindings.length > 0) spec.bindings = bindings;
151
+ if (wiring) spec.services = wiring.services;
152
+ if (mapped && mapped.crons.length > 0) spec.crons = mapped.crons;
153
+ if (mapped && mapped.queue_consumers.length > 0) spec.queue_consumers = mapped.queue_consumers;
147
154
  if (secrets && Object.keys(secrets).length > 0) spec.secrets_patch = { set: secrets };
148
155
  }
149
156
 
@@ -169,11 +176,14 @@ export type CloudDeployResult = {
169
176
  };
170
177
 
171
178
  type SpecAdditions = {
172
- bindings?: ServiceBindingEntry[];
179
+ bindings?: (RecordBinding | ServiceBindingEntry)[];
173
180
  services?: Record<string, string>;
181
+ crons?: string[];
182
+ queue_consumers?: RecordQueueConsumer[];
174
183
  secrets_patch?: { set: Record<string, string> };
175
184
  };
176
- type AppSpec = { module: { sha256: string }; hosts: string[]; assets_manifest_ref?: string } & SpecAdditions;
185
+ type HostsField = { hosts_patch: { add: string[] } } | { hosts: string[] };
186
+ type AppSpec = { module: { sha256: string }; assets_manifest_ref?: string } & HostsField & SpecAdditions;
177
187
  type Call = { method: string; path: string; body?: BodyInit; json?: object; ok?: number[] };
178
188
  type CallResult = { status: number; text: string; body?: JsonObject; headers: Headers };
179
189
  type CallFn = (c: Call) => Promise<CallResult>;
@@ -201,14 +211,41 @@ export function makeCall(plan: CloudCredentials): CallFn {
201
211
  };
202
212
  }
203
213
 
204
- /** What the app holds now: tells a first deploy from an update, and keeps its assets when this deploy has none. */
214
+ /**
215
+ * Send an app spec that adds the deploy's own hosts (the `<project>.<team>.sproutboat.app` name) to the app's routes and
216
+ * removes none. `hosts` on the Cloud is the whole list, so sending only the default host would unroute every custom domain an
217
+ * operator added; `hosts_patch` leaves the others alone, as `wrangler deploy` leaves dashboard routes when the config declares
218
+ * none. A Cloud that predates `hosts_patch` refuses the key by name; then the live routes (read at the start of this deploy)
219
+ * are merged here and sent as `hosts`.
220
+ */
221
+ async function sendWithHosts(
222
+ plan: CloudCredentials,
223
+ call: CallFn,
224
+ wanted: string[],
225
+ live: string[],
226
+ io: CloudIo,
227
+ request: (hosts: HostsField) => Call,
228
+ ): Promise<CallResult> {
229
+ const first = request({ hosts_patch: { add: wanted } });
230
+ const answer = await call({ ...first, ok: [...(first.ok ?? []), 400] });
231
+ if (answer.status !== 400) return answer;
232
+ const body = answer.body;
233
+ if (body?.path !== "hosts_patch" || !String(body.error).startsWith("unknown key"))
234
+ throw new CloudError(explainApiFailure(400, answer.text, plan.tokenFromEnv));
235
+ io.log(dim(" hosts this Cloud has no hosts_patch yet; sending its current routes back with this deploy's"));
236
+ return call(request({ hosts: [...live, ...wanted.filter((host) => !live.includes(host))] }));
237
+ }
238
+
239
+ /** What the app holds now: tells a first deploy from an update, keeps its assets when this deploy has none, and lists its routes. */
205
240
  async function resolveAssets(
206
241
  call: CallFn,
207
242
  plan: { project: string; assets?: AssetBundle },
208
243
  io: CloudIo,
209
- ): Promise<{ created: boolean; assetsRef: string | undefined }> {
244
+ ): Promise<{ created: boolean; assetsRef: string | undefined; hosts: string[] }> {
210
245
  const current = await call({ method: "GET", path: `/v1/apps/${plan.project}`, ok: [404] });
211
246
  const created = current.status === 404;
247
+ const live = jsonObject(current.body?.spec ?? null)?.hosts;
248
+ const hosts = Array.isArray(live) ? live.filter(isString) : [];
212
249
  let assetsRef: string | undefined;
213
250
  if (plan.assets) {
214
251
  await pushAssets(call, plan.assets, io);
@@ -221,7 +258,7 @@ async function resolveAssets(
221
258
  io.log(` assets keeping the current ${ref.slice(0, 19)}`);
222
259
  }
223
260
  }
224
- return { created, assetsRef };
261
+ return { created, assetsRef, hosts };
225
262
  }
226
263
 
227
264
  /** Wait for a host to apply `generation`; false at the deadline. */
@@ -255,17 +292,19 @@ export async function deployToCloud(plan: CloudDeployPlan, io: CloudIo): Promise
255
292
  const call = makeCall(plan);
256
293
  checkServiceBindings(plan.project, plan.services ?? [], plan.compatibilityDate);
257
294
  const wiring = await resolveServiceBindings(call, plan.project, plan.services ?? []);
258
- const { created, assetsRef } = await resolveAssets(call, plan, io);
295
+ const { created, assetsRef, hosts: liveHosts } = await resolveAssets(call, plan, io);
259
296
 
260
297
  const bytes = new Uint8Array(readFileSync(plan.modulePath));
261
298
  const sha = createHash("sha256").update(bytes).digest("hex");
262
299
  io.log(` module ${sha.slice(0, 12)} (${(bytes.byteLength / 1024).toFixed(0)} KiB)`);
263
300
  await call({ method: "PUT", path: `/v1/modules/${sha}`, body: bytes });
264
301
 
265
- const spec: AppSpec = { module: { sha256: sha }, hosts: plan.hosts };
266
- if (assetsRef !== undefined) spec.assets_manifest_ref = assetsRef;
267
- applyExtras(spec, wiring, plan.secrets);
268
- const put = await call({ method: "PUT", path: `/v1/apps/${plan.project}`, json: spec });
302
+ const put = await sendWithHosts(plan, call, plan.hosts, liveHosts, io, (hosts) => {
303
+ const spec: AppSpec = { module: { sha256: sha }, ...hosts };
304
+ if (assetsRef !== undefined) spec.assets_manifest_ref = assetsRef;
305
+ applyExtras(spec, wiring, plan.secrets, plan.cloudBindings);
306
+ return { method: "PUT", path: `/v1/apps/${plan.project}`, json: spec };
307
+ });
269
308
  const generation = put.body?.record_generation;
270
309
  if (!isSafeInteger(generation)) throw new CloudError("the Cloud's reply carried no record generation");
271
310
  const url = plan.hosts[0] === undefined ? undefined : `https://${plan.hosts[0]}`;
@@ -352,7 +391,7 @@ export const SOURCE_MAX_BYTES = 8 * 1024 * 1024;
352
391
  /** Ctrl-C while the build runs: the build was asked to cancel (exit 130). */
353
392
  export class Interrupted extends CloudError {}
354
393
 
355
- type BuildSpec = { hosts: string[]; assets_manifest_ref?: string } & SpecAdditions;
394
+ type BuildSpec = { assets_manifest_ref?: string } & HostsField & SpecAdditions;
356
395
  type BuildCli = { version: string; runtime?: string; toolchain?: string };
357
396
 
358
397
  export type BuildPlan = CloudCredentials &
@@ -674,7 +713,7 @@ export async function buildAndDeploy(plan: BuildPlan, io: CloudIo, signal?: Abor
674
713
  "--secrets-file is applied once the build is ready, so it cannot be combined with --no-wait; drop --no-wait, or set the secrets afterwards with `sproutboat secrets put`",
675
714
  );
676
715
  const wiring = await resolveServiceBindings(call, plan.project, plan.services ?? []);
677
- const { created, assetsRef } = await resolveAssets(call, plan, io);
716
+ const { created, assetsRef, hosts: liveHosts } = await resolveAssets(call, plan, io);
678
717
 
679
718
  const missingBody = (await call({ method: "POST", path: "/v1/sources/missing", json: { sha256: [sha] } })).body
680
719
  ?.missing;
@@ -685,19 +724,22 @@ export async function buildAndDeploy(plan: BuildPlan, io: CloudIo, signal?: Abor
685
724
  await call({ method: "PUT", path: `/v1/sources/${sha}`, body: bytes });
686
725
  } else io.log(`${label} unchanged`);
687
726
 
688
- const spec: BuildSpec = { hosts: plan.hosts };
689
- if (assetsRef !== undefined) spec.assets_manifest_ref = assetsRef;
690
- // The build spec is stored in plaintext by the Cloud: secrets never go in it (they follow the build, below).
691
- applyExtras(spec, wiring, undefined);
692
727
  const cli: BuildCli = { version: plan.cli.version };
693
728
  if (plan.cli.runtime) cli.runtime = plan.cli.runtime;
694
729
  if (plan.cli.toolchain) cli.toolchain = plan.cli.toolchain;
695
730
  const build = { v: 1, name: plan.project, cli };
696
- const accepted = await call({
697
- method: "POST",
698
- path: `/v1/apps/${plan.project}/builds`,
699
- json: { source_sha256: sha, kind: "generated", build, spec },
700
- ok: [429, 503],
731
+ // The routes are merged when the build is applied, minutes from now, so one added meanwhile is kept too.
732
+ const accepted = await sendWithHosts(plan, call, plan.hosts, liveHosts, io, (hosts) => {
733
+ const spec: BuildSpec = { ...hosts };
734
+ if (assetsRef !== undefined) spec.assets_manifest_ref = assetsRef;
735
+ // The build spec is stored in plaintext by the Cloud: secrets never go in it (they follow the build, below).
736
+ applyExtras(spec, wiring, undefined, plan.cloudBindings);
737
+ return {
738
+ method: "POST",
739
+ path: `/v1/apps/${plan.project}/builds`,
740
+ json: { source_sha256: sha, kind: "generated", build, spec },
741
+ ok: [429, 503],
742
+ };
701
743
  });
702
744
  if (accepted.status === 429 || accepted.status === 503) throw new CloudError(explainBuildRefusal(accepted));
703
745
  let view = parseBuildView(accepted.body);
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `sproutboat domains` on the Sproutboat Cloud. The Cloud has no domains resource: an app's routes are the `hosts` of its
3
+ * spec. `list` reads them. Adding or removing a custom domain is an operator step for now (it needs a DNS record and a
4
+ * route on the Cloud's edge as well as the entry in the spec), so the other verbs say so instead of calling a route the
5
+ * Cloud does not have.
6
+ */
7
+ import { CloudError } from "./cloud";
8
+ import { makeCall, type CloudCredentials } from "./cloud-deploy";
9
+ import { isSafeInteger, isString, jsonObject } from "./json";
10
+
11
+ /** Names under this zone belong to the platform: `<project>.<team>.sproutboat.app` is every app's own route. */
12
+ export const PLATFORM_ZONE = ".sproutboat.app";
13
+
14
+ export type CloudRoute = { route: string; custom: boolean };
15
+ export type CloudRoutes = { routes: CloudRoute[]; applied: boolean | undefined; generation: number | undefined };
16
+
17
+ /** The routes of a deployed app, in the order the spec holds them, each marked custom when it is not under sproutboat.app. */
18
+ export async function listCloudRoutes(credentials: CloudCredentials, project: string): Promise<CloudRoutes> {
19
+ const result = await makeCall(credentials)({ method: "GET", path: `/v1/apps/${project}`, ok: [404] });
20
+ if (result.status === 404)
21
+ throw new CloudError(`${project} is not deployed yet, so it has no routes: run \`sproutboat deploy\` first`);
22
+ const hosts = jsonObject(result.body?.spec ?? null)?.hosts;
23
+ const routes = (Array.isArray(hosts) ? hosts.filter(isString) : []).map((route) => ({
24
+ route,
25
+ custom: !route.split("/")[0]!.endsWith(PLATFORM_ZONE),
26
+ }));
27
+ const apply = jsonObject(result.body?.apply ?? null);
28
+ const generation = result.body?.record_generation;
29
+ return {
30
+ routes,
31
+ applied: apply ? apply.state === "applied" : undefined,
32
+ generation: isSafeInteger(generation) ? generation : undefined,
33
+ };
34
+ }
35
+
36
+ /** The sentence for `add`, `verify` and `delete` against a Cloud. */
37
+ export function cloudDomainsUnsupported(sub: string, host: string): string {
38
+ const verb = sub === "delete" ? "removed" : "added";
39
+ return [
40
+ `custom domains on the Cloud are ${verb} by a Sproutboat operator for now, so \`domains ${sub}\` changes nothing here.`,
41
+ `Ask for ${host} to be routed to this app; \`sproutboat domains list\` shows what is routed, and \`sproutboat deploy\``,
42
+ "keeps every route it did not add itself, so a domain an operator routed stays through later deploys.",
43
+ ].join("\n");
44
+ }
45
+
46
+ /** The lines `domains list` prints for a Cloud app. */
47
+ export function describeCloudRoutes(project: string, listed: CloudRoutes): string[] {
48
+ if (listed.routes.length === 0) return [`${project} has no routes yet`];
49
+ const width = Math.max(...listed.routes.map((entry) => entry.route.length));
50
+ const lines = listed.routes.map((entry) =>
51
+ `${(entry.custom ? "custom" : "built-in").padEnd(9)} ${entry.route.padEnd(width)}`.trimEnd(),
52
+ );
53
+ if (listed.applied === false) lines.push("(the newest record is not applied on a host yet)");
54
+ return lines;
55
+ }
package/src/cloud-fake.ts CHANGED
@@ -3,7 +3,7 @@ import { Database } from "bun:sqlite";
3
3
  import { createHash } from "node:crypto";
4
4
 
5
5
  import { parseBody } from "./cloud";
6
- import { isString, jsonObject, type JsonObject } from "./json";
6
+ import { isSafeInteger, isString, jsonObject, type JsonObject, type JsonValue } from "./json";
7
7
  import { splitSql } from "./sql-split";
8
8
 
9
9
  export const FAKE_KEY = "sbk_fakekey0123456789";
@@ -14,6 +14,10 @@ export type FakeOptions = {
14
14
  role?: string;
15
15
  applyStates?: string[];
16
16
  existingAssetsRef?: string;
17
+ /** Hosts the app already routes (an operator added them): the app exists with these in its spec. */
18
+ existingHosts?: string[];
19
+ /** A Cloud that predates `hosts_patch`: a spec carrying it is a 400 naming the key, as the real one answered. */
20
+ legacyHosts?: boolean;
17
21
  appExists?: boolean;
18
22
  /** Fail every authenticated call with this status. */
19
23
  failWith?: { status: number; body: JsonObject };
@@ -58,6 +62,55 @@ const MiB = 1048576;
58
62
  export const FAKE_OWNER_SUFFIX = "0123456789ab";
59
63
  export const fakeAppId = (project: string) => `${project}_${FAKE_OWNER_SUFFIX}`;
60
64
 
65
+ /** The Cloud's record rules for bindings, crons and queue consumers (sproutboat-cloud src/record.ts), as the first error or null. */
66
+ function invalidRecordReason(sent: JsonObject): string | null {
67
+ const resourceId = /^[a-z0-9][a-z0-9_-]{0,63}$/;
68
+ const kinds = ["kv", "d1", "r2", "queue", "do", "ae", "ratelimit", "assets", "secret", "service"];
69
+ const bindings = Array.isArray(sent.bindings) ? sent.bindings : [];
70
+ if (bindings.length > 64) return "bindings must be an array of at most 64 entries";
71
+ const names = new Set<string>();
72
+ for (const [i, entry] of bindings.entries()) {
73
+ const b = jsonObject(entry);
74
+ if (!b) return `bindings[${i}] must be an object`;
75
+ if (!isString(b.name) || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(b.name)) return `invalid bindings[${i}].name`;
76
+ if (names.has(b.name)) return "duplicate binding name";
77
+ names.add(b.name);
78
+ if (!isString(b.kind) || !kinds.includes(b.kind)) return "unknown binding kind";
79
+ if (b.resource_id === null) {
80
+ if (["kv", "d1", "r2", "queue"].includes(b.kind)) return `${b.kind} binding needs a resource_id`;
81
+ } else if (!isString(b.resource_id) || !resourceId.test(b.resource_id)) return `invalid bindings[${i}].resource_id`;
82
+ if (!jsonObject(b.config ?? null)) return "config must be an object";
83
+ }
84
+ const crons = sent.crons ?? [];
85
+ if (!Array.isArray(crons) || crons.length > 16) return "crons must be an array of at most 16 entries";
86
+ for (const c of crons)
87
+ if (
88
+ !isString(c) ||
89
+ c.length > 128 ||
90
+ !/^[0-9A-Za-z*,/ -]+$/.test(c) ||
91
+ c.split(" ").length !== 5 ||
92
+ c.split(" ").some((f) => f === "")
93
+ )
94
+ return "invalid cron expression";
95
+ const consumers = sent.queue_consumers ?? [];
96
+ if (!Array.isArray(consumers) || consumers.length > 16)
97
+ return "queue_consumers must be an array of at most 16 entries";
98
+ const within = (v: JsonValue | undefined, lo: number, hi: number) => isSafeInteger(v) && v >= lo && v <= hi;
99
+ for (const entry of consumers) {
100
+ const q = jsonObject(entry);
101
+ if (!q || !isString(q.queue) || !resourceId.test(q.queue)) return "invalid queue_consumers.queue";
102
+ if (
103
+ !within(q.max_batch_size, 1, 100) ||
104
+ !within(q.max_batch_timeout_ms, 0, 60000) ||
105
+ !within(q.max_retries, 0, 100)
106
+ )
107
+ return "queue consumer settings out of range";
108
+ if (q.dead_letter_queue !== null && (!isString(q.dead_letter_queue) || !resourceId.test(q.dead_letter_queue)))
109
+ return "invalid queue_consumers.dead_letter_queue";
110
+ }
111
+ return null;
112
+ }
113
+
61
114
  export function startFakeCloud(options: FakeOptions = {}) {
62
115
  const calls: { method: string; path: string; body: string; auth: string | null }[] = [];
63
116
  const modules = new Set<string>();
@@ -80,14 +133,30 @@ export function startFakeCloud(options: FakeOptions = {}) {
80
133
  const dataDenied = (need: string) =>
81
134
  Response.json({ error: "forbidden", reason: "forbidden", need }, { status: 403 });
82
135
  const permissionDenied = (need: string) => Response.json({ error: "forbidden", need }, { status: 403 });
136
+ /** The Cloud's rules for `hosts` and `hosts_patch` (apps.ts normalizeSpec), as an error reply or null. */
137
+ const checkHosts = (sent: JsonObject): Response | null => {
138
+ const refuse = (error: string, path: string) => Response.json({ error, reason: "invalid", path }, { status: 400 });
139
+ if (sent.hosts_patch === undefined) return null;
140
+ if (options.legacyHosts) return refuse("unknown key hosts_patch", "hosts_patch");
141
+ if (sent.hosts !== undefined) return refuse("hosts and hosts_patch are mutually exclusive", "hosts_patch");
142
+ const add = jsonObject(sent.hosts_patch)?.add;
143
+ for (const [i, host] of (Array.isArray(add) ? add : []).entries())
144
+ if (!isString(host) || !/^(?=.{1,253}$)[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(\/.*)?$/.test(host))
145
+ return refuse(`hosts_patch.add[${i}]: ${JSON.stringify(host)} is not a valid route`, `hosts_patch.add[${i}]`);
146
+ return null;
147
+ };
83
148
  /** The Cloud's spec rules for service bindings and secrets, then the merge. Returns an error reply or null. */
84
149
  const checkAndStore = (sent: JsonObject, project: string): Response | null => {
150
+ const refusedHosts = checkHosts(sent);
151
+ if (refusedHosts) return refusedHosts;
85
152
  const patch = jsonObject(sent.secrets_patch ?? null);
86
153
  if (sent.secrets !== undefined && patch)
87
154
  return Response.json({ error: "secrets and secrets_patch are exclusive" }, { status: 400 });
88
155
  if ((patch || sent.secrets !== undefined) && denied.has("secret:write")) return permissionDenied("secret:write");
89
156
  const bindings = Array.isArray(sent.bindings) ? sent.bindings.map((b) => jsonObject(b)) : [];
90
157
  const services = jsonObject(sent.services ?? null) ?? {};
158
+ const invalidRecord = invalidRecordReason(sent);
159
+ if (invalidRecord) return Response.json({ error: invalidRecord, reason: "invalid" }, { status: 400 });
91
160
  for (const b of bindings)
92
161
  if (b?.kind === "service" && !(String(b.name) in services))
93
162
  return Response.json({ error: `service binding ${String(b.name)} has no services entry` }, { status: 400 });
@@ -111,10 +180,22 @@ export function startFakeCloud(options: FakeOptions = {}) {
111
180
  return null;
112
181
  };
113
182
  const stored = (sent: JsonObject): JsonObject => {
114
- const { secrets: _a, secrets_patch: _b, ...rest } = sent;
115
- return rest;
183
+ const { secrets: _a, secrets_patch: _b, hosts_patch: patch, ...rest } = sent;
184
+ const edit = jsonObject(patch ?? null);
185
+ if (!edit) return rest;
186
+ // hosts_patch merges into the live routes (an app that does not exist yet starts with none): removals first, then additions.
187
+ const live = Array.isArray(spec.hosts) ? spec.hosts.filter(isString) : [];
188
+ const drop = new Set(Array.isArray(edit.remove) ? edit.remove : []);
189
+ const hosts = live.filter((host) => !drop.has(host));
190
+ for (const host of Array.isArray(edit.add) ? edit.add : [])
191
+ if (isString(host) && !hosts.includes(host)) hosts.push(host);
192
+ return { ...rest, hosts };
116
193
  };
117
194
  if (options.existingAssetsRef) spec.assets_manifest_ref = options.existingAssetsRef;
195
+ if (options.existingHosts) {
196
+ spec.hosts = options.existingHosts;
197
+ appExists = true;
198
+ }
118
199
 
119
200
  const databases = new Map<string, Database>((options.d1 ?? []).map((name) => [name, new Database(":memory:")]));
120
201
  const d1Requests: JsonObject[] = [];
@@ -165,7 +246,10 @@ export function startFakeCloud(options: FakeOptions = {}) {
165
246
  error: b.error,
166
247
  module_sha256: b.state === "ready" ? "f".repeat(64) : null,
167
248
  record_generation: b.generation,
168
- hosts: jsonObject(b.body.spec ?? null)?.hosts ?? [],
249
+ hosts:
250
+ jsonObject(b.body.spec ?? null)?.hosts ??
251
+ jsonObject(jsonObject(b.body.spec ?? null)?.hosts_patch ?? null)?.add ??
252
+ [],
169
253
  input_hash: "1".repeat(64),
170
254
  queued_at: `2026-11-11T10:00:0${b.id.slice(-1)}.000Z`,
171
255
  finished_at:
@@ -317,6 +401,8 @@ export function startFakeCloud(options: FakeOptions = {}) {
317
401
  const buildSpec = jsonObject(sent.spec ?? null) ?? {};
318
402
  if ("secrets" in buildSpec || "secrets_patch" in buildSpec)
319
403
  return Response.json({ error: "secrets do not belong in a build spec" }, { status: 400 });
404
+ const refusedHosts = checkHosts(buildSpec);
405
+ if (refusedHosts) return refusedHosts;
320
406
  if (!sources.has(sent.source_sha256))
321
407
  return Response.json({ error: "source not uploaded", reason: "source-missing" }, { status: 409 });
322
408
  buildRequests.push(sent);