sproutboat 0.53.1 → 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,100 @@
1
+ import { parseConfig, type ConfigValidation } from "./config";
2
+ import { isString, jsonObject, parseJsonValue, type JsonObject, type JsonValue } from "./json";
3
+
4
+ /** Blank out comments and trailing commas the way parseConfig does, then parse. Undefined when it is not a JSON object. */
5
+ function parseLoose(source: string): JsonObject | undefined {
6
+ let json = "";
7
+ let quoted = false;
8
+ for (let index = 0; index < source.length; index++) {
9
+ const character = source[index];
10
+ if (character === '"' && source[index - 1] !== "\\") quoted = !quoted;
11
+ if (!quoted && character === "/" && source[index + 1] === "/") {
12
+ index = source.indexOf("\n", index);
13
+ if (index < 0) break;
14
+ json += "\n";
15
+ } else if (!quoted && character === "/" && source[index + 1] === "*") {
16
+ index = source.indexOf("*/", index + 2);
17
+ if (index < 0) return undefined;
18
+ index++;
19
+ } else json += character;
20
+ }
21
+ try {
22
+ return jsonObject(parseJsonValue(json.replace(/,\s*([}\]])/g, "$1")));
23
+ } catch {
24
+ return undefined;
25
+ }
26
+ }
27
+
28
+ const ACCOUNT_RE = /^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$/;
29
+
30
+ /**
31
+ * The `account` field of sproutboat.jsonc (like Wrangler's `account_id`): the Cloud team a deploy targets.
32
+ * It belongs to the CLI, not to the app, so the shared config schema does not know it.
33
+ */
34
+ export function configAccount(source: string): string | undefined {
35
+ const account = parseLoose(source)?.account;
36
+ return isString(account) && account !== "" ? account : undefined;
37
+ }
38
+
39
+ /**
40
+ * `migrations_dir` (like Wrangler's, but top level: a Sproutboat app has its D1 databases by binding name only): the
41
+ * directory `d1 migrations` keeps its .sql files in, relative to the project. Defaults to `migrations`. Undefined when
42
+ * absent; an invalid value is reported by parseProjectConfig.
43
+ */
44
+ export function configMigrationsDir(source: string): string | undefined {
45
+ const dir = parseLoose(source)?.migrations_dir;
46
+ return isString(dir) && dir !== "" ? dir : undefined;
47
+ }
48
+
49
+ const MIGRATIONS_DIR_ERROR = 'migrations_dir must be a relative folder inside the project (for example "migrations")';
50
+ const validMigrationsDir = (dir: JsonValue | undefined): boolean =>
51
+ isString(dir) && dir !== "" && !dir.startsWith("/") && !/^[A-Za-z]:/.test(dir) && !dir.split(/[\\/]/).includes("..");
52
+
53
+ /** The `main` a project with assets and no `main` gets: a file that does not exist, written by `readProject` into `.sproutboat/`. */
54
+ export const ASSETS_ONLY_MAIN = "src/.assets-only.js";
55
+
56
+ /**
57
+ * The entry a Worker with only static assets runs, as Wrangler allows (`assets` and no `main`). The platform serves a matching
58
+ * file before any code runs; a request that matches none reaches this, and `env.<binding>.fetch` answers it with the project's
59
+ * `not_found_handling`: the single-page app shell, the nearest 404 page, or a plain 404.
60
+ */
61
+ export function assetsOnlySource(binding: string): string {
62
+ return [
63
+ "// Written by sproutboat: this project has `assets` and no `main`, so it is a Worker with only static assets.",
64
+ "export default {",
65
+ " fetch(request, env) {",
66
+ ` return env.${binding}.fetch(request);`,
67
+ " },",
68
+ "};",
69
+ "",
70
+ ].join("\n");
71
+ }
72
+
73
+ /** `assets` with a directory and no `main`: the assets-only case. The entry and the binding it calls are filled in. */
74
+ function withAssetsOnlyEntry(config: JsonObject): JsonObject {
75
+ const assets = jsonObject(config.assets ?? null);
76
+ if (config.main !== undefined || assets === undefined) return config;
77
+ return { ...config, main: ASSETS_ONLY_MAIN, assets: { ...assets, binding: assets.binding ?? "ASSETS" } };
78
+ }
79
+
80
+ /**
81
+ * parseConfig for a file that may carry the CLI-owned fields `account` and `migrations_dir`: they are validated here and
82
+ * removed before the shared schema sees them.
83
+ */
84
+ export function parseProjectConfig(source: string): ConfigValidation {
85
+ const loose = parseLoose(source);
86
+ if (loose === undefined) return parseConfig(source);
87
+ if (!("account" in loose || "migrations_dir" in loose)) {
88
+ const filled = withAssetsOnlyEntry(loose);
89
+ return filled === loose ? parseConfig(source) : parseConfig(JSON.stringify(filled));
90
+ }
91
+ const errors: string[] = [];
92
+ if ("account" in loose && (!isString(loose.account) || !ACCOUNT_RE.test(loose.account)))
93
+ errors.push("account must be the URL name of a team (lowercase letters, digits and dashes)");
94
+ if ("migrations_dir" in loose && !validMigrationsDir(loose.migrations_dir)) errors.push(MIGRATIONS_DIR_ERROR);
95
+ if (errors.length > 0) return { ok: false, errors };
96
+ const rest = Object.fromEntries(
97
+ Object.entries(loose).filter(([key]) => key !== "account" && key !== "migrations_dir"),
98
+ );
99
+ return parseConfig(JSON.stringify(withAssetsOnlyEntry(rest)));
100
+ }
package/src/build.ts CHANGED
@@ -7,7 +7,7 @@ import { queueSettings, resourceRefs, type SproutboatConfig } from "./config";
7
7
  import { ensureSqliteObject } from "./sqlite";
8
8
  import { sqliteStamp } from "./sqlite";
9
9
  import { bearsslStamp, ensureBearssl } from "./bearssl";
10
- import { compileSprout, loadPrelude, porfforChannel, type Transport } from "./compile";
10
+ import { compileSprout, loadPrelude, ltoEnabled, porfforChannel, type Transport } from "./compile";
11
11
  import { compileCached } from "./compile-cache";
12
12
  import type { BuildTarget } from "./targets";
13
13
  import {
@@ -51,6 +51,13 @@ export type BuildInput = {
51
51
  * for the fast path deliberately.
52
52
  */
53
53
  optimize?: "dev" | "release";
54
+ /**
55
+ * Link-time optimization for the alpha 22 standalone build. Defaults to on (`-flto -O3`, the release
56
+ * build). `false` keeps -O3 per unit but links without LTO: about 1.5 to 2x faster cold, a link of
57
+ * ~90 MB instead of 1.1 to 1.6 GB, and a binary that serves fewer requests per second. A separate
58
+ * input to the compile cache key, so the two kinds of binary never share an entry.
59
+ */
60
+ lto?: boolean;
54
61
  /** Reuse a previously compiled native sprout when only sidecar assets changed. */
55
62
  reuseSproutPath?: string;
56
63
  /** An isolated artifact directory, used by dev candidates to avoid mixing snapshots. */
@@ -297,6 +304,7 @@ export async function emitGenerated(input: BuildInput): Promise<{ generatedPath:
297
304
  appName: input.config.name,
298
305
  assets: bakedAssets,
299
306
  optimize: input.optimize,
307
+ lto: input.lto,
300
308
  versionMetadata,
301
309
  emitOnly: true,
302
310
  withRestore: input.withRestore,
@@ -334,6 +342,8 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
334
342
  generatedSource,
335
343
  target,
336
344
  optimize: input.optimize ?? "release",
345
+ // a non-LTO link is a different binary from the release one; only the alpha 21 line's channels honour it
346
+ lto: porfforChannel(input.withRestore) !== "alpha-19" ? ltoEnabled({ ...input, target }) : true,
337
347
  toolchain: toolchainStamp(),
338
348
  toolchainPackageVersion,
339
349
  porffor: [PORFFOR_COMMIT_FULL, PORFFOR_ARCHIVE_SHA256],
@@ -341,7 +351,7 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
341
351
  channel: porfforChannel(input.withRestore),
342
352
  // the alpha 22 channel's own inputs: the patched tree, the shim, the bridge, the build recipe, LTO
343
353
  channelInputs:
344
- porfforChannel(input.withRestore) === "alpha-22"
354
+ porfforChannel(input.withRestore) !== "alpha-19"
345
355
  ? [A21_STANDALONE_FINGERPRINT, process.env.SPROUTBOAT_FLTO ?? ""]
346
356
  : null,
347
357
  native: embedded ? [sqliteStamp(), bearsslStamp()] : subtle ? [bearsslStamp()] : [],
@@ -390,6 +400,7 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
390
400
  ],
391
401
  extraCflags: tls ? ["-I", tls.includeDir] : [],
392
402
  optimize: input.optimize,
403
+ lto: input.lto,
393
404
  versionMetadata,
394
405
  withRestore: input.withRestore,
395
406
  });
@@ -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
+ }