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.
- package/CHANGELOG.md +32 -0
- package/README.md +32 -8
- package/SURFACE.md +7 -7
- package/package.json +20 -12
- package/scripts/embed-templates.ts +87 -0
- package/src/account-field.ts +33 -2
- package/src/build.ts +3 -3
- package/src/cloud-bindings.ts +227 -0
- package/src/cloud-deploy.ts +67 -25
- package/src/cloud-domains.ts +55 -0
- package/src/cloud-fake.ts +90 -4
- package/src/compile.ts +18 -15
- package/src/dev.ts +67 -4
- package/src/init.ts +328 -0
- package/src/main.ts +112 -132
- package/src/options.ts +13 -4
- package/src/surface.ts +12 -11
- package/src/templates-embedded.ts +72 -0
- package/src/templates.ts +53 -0
- package/src/toolchain.ts +3 -0
- package/src/types-cmd.ts +193 -0
- package/src/types-gen.ts +104 -68
- package/types/cloudflare-workers.d.ts +43 -0
- package/types/sproutboat.d.ts +160 -3
|
@@ -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
|
+
}
|
package/src/cloud-deploy.ts
CHANGED
|
@@ -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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
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 = {
|
|
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
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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
|
-
|
|
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:
|
|
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);
|