sproutboat 0.7.0 → 0.9.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
@@ -3,7 +3,9 @@ import { cp, mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
3
  import { resolve } from "node:path";
4
4
  import { walkAssets, type AssetManifest } from "./assets";
5
5
  import { resourceRefs, type SproutboatConfig } from "./config";
6
- import { compileSprout } from "./compile";
6
+ import { ensureSqliteObject } from "./sqlite";
7
+ import { ensureBearssl } from "./bearssl";
8
+ import { compileSprout, type Transport } from "./compile";
7
9
  import {
8
10
  ARTIFACT_SCHEMA_VERSION,
9
11
  CAPABILITY_PROFILE,
@@ -30,6 +32,16 @@ export type BuildInput = {
30
32
  * the real target, which is what stops the result being deployed.
31
33
  */
32
34
  target?: "linux-x86_64" | "host";
35
+ /** #15 — `embedded` compiles SQLite into the sprout instead of a broker
36
+ * transport. Defaults to the broker transport. */
37
+ transport?: Transport;
38
+ /**
39
+ * `dev` compiles at -O0, which is roughly three times faster and produces a
40
+ * bigger binary. Only for artifacts that cannot be deployed: `deploy` refuses
41
+ * a host build already, and this defaults to `release` so a caller has to ask
42
+ * for the fast path deliberately.
43
+ */
44
+ optimize?: "dev" | "release";
33
45
  };
34
46
 
35
47
  export type BuildOutput = {
@@ -37,6 +49,10 @@ export type BuildOutput = {
37
49
  manifest: ArtifactManifest;
38
50
  };
39
51
 
52
+ /** Baking megabytes of assets into the module makes the Porffor compile crawl;
53
+ * past this it is the wrong tool and the error says what to do instead. */
54
+ const MAX_BAKED_ASSET_BYTES = 8_000_000;
55
+
40
56
  function digest(value: Uint8Array | string): `sha256:${string}` {
41
57
  return `sha256:${createHash("sha256").update(value).digest("hex")}`;
42
58
  }
@@ -77,6 +93,7 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
77
93
  queues: refsByKind.queue.map((ref) => ref.binding),
78
94
  analytics: input.config.analytics_engine_datasets ?? [],
79
95
  do: Object.entries(input.config.durable_objects ?? {}).map(([binding, className]) => ({ binding, className })),
96
+ services: input.config.services ?? [],
80
97
  crons: input.config.triggers?.crons ?? [],
81
98
  assets: input.config.assets?.binding ?? "",
82
99
  // Baked plain values, read as env.NAME. The broker never serves these (they
@@ -91,6 +108,43 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
91
108
  // for it — that download is the slowest part of a first local build.
92
109
  const host = input.target === "host";
93
110
  const zigBin = host ? undefined : await ensureZig();
111
+ // #15 — an embedded sprout carries its own storage and TLS instead of talking
112
+ // to a broker: SQLite and BearSSL are compiled once per target and added to
113
+ // the link line, and BearSSL's header to the compile line.
114
+ const embedded = input.transport === "embedded";
115
+ const target = input.target ?? "linux-x86_64";
116
+ const sqliteObject = embedded ? await ensureSqliteObject({ target, zigBin }) : null;
117
+ const tls = embedded ? await ensureBearssl({ target, zigBin }) : null;
118
+ const extraLink = [...(sqliteObject ? [sqliteObject] : []), ...(tls ? tls.objects : [])];
119
+ const extraCflags = tls ? ["-I", tls.includeDir] : [];
120
+ // #15 — an embedded binary has no files beside it, so assets are baked into
121
+ // the module. Read them from the source directory: the artifact copy happens
122
+ // after the compile, and the compile is what needs them. Bytes travel as a
123
+ // latin1 string, one char per byte, which is what the asset shim hands back.
124
+ let bakedAssets: { manifest: AssetManifest; files: Record<string, string> } | undefined;
125
+ if (input.transport === "embedded" && input.config.assets) {
126
+ const dir = resolve(input.projectDir, input.config.assets.directory);
127
+ const manifest: AssetManifest = {
128
+ notFound: input.config.assets.not_found_handling ?? "none",
129
+ runSproutFirst: input.config.assets.run_sprout_first ?? false,
130
+ files: walkAssets(dir),
131
+ };
132
+ const files: Record<string, string> = {};
133
+ let total = 0;
134
+ for (const key of Object.keys(manifest.files)) {
135
+ const bytes = await readFile(resolve(dir, `.${key}`));
136
+ total += bytes.byteLength;
137
+ if (total > MAX_BAKED_ASSET_BYTES) {
138
+ throw new Error(
139
+ `assets are too large to compile into a standalone binary (over ${MAX_BAKED_ASSET_BYTES / 1_000_000} MB). ` +
140
+ "Serve them from R2, or drop the assets binding and put a web server in front.",
141
+ );
142
+ }
143
+ files[key] = bytes.toString("latin1");
144
+ }
145
+ bakedAssets = { manifest, files };
146
+ }
147
+
94
148
  await compileSprout({
95
149
  sourcePath: input.sourcePath,
96
150
  source: input.source,
@@ -100,6 +154,12 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
100
154
  zigBin,
101
155
  target: input.target,
102
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,
103
163
  });
104
164
 
105
165
  const sprout = await readFile(sproutPath);
package/src/compile.ts CHANGED
@@ -12,8 +12,17 @@
12
12
  import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
13
13
  import { dirname, resolve } from "node:path";
14
14
  import { ensurePorfforPatched } from "./patch-porffor";
15
- import { ensureUWebSockets, porfforRoot, UwsUnavailableError } from "./toolchain";
16
- import { EMPTY_BINDINGS, preludePath, wrapNativeFetchHandler, type Bindings } from "./wrap";
15
+ import { ensureUWebSockets, ensureUWebSocketsHost, porfforRoot, UwsUnavailableError } from "./toolchain";
16
+ import {
17
+ EMPTY_BINDINGS,
18
+ preludePath,
19
+ transportPath,
20
+ wrapNativeFetchHandler,
21
+ TRANSPORT_MARKER,
22
+ type Bindings,
23
+ type Transport,
24
+ } from "./wrap";
25
+ export type { Transport } from "./wrap";
17
26
 
18
27
  export {
19
28
  BASELINE_COMPATIBILITY_DATE,
@@ -35,6 +44,26 @@ export type CompileInput = {
35
44
  /** The project's `compatibility_date`, baked in so the runtime can gate a
36
45
  * behaviour change on it. Defaults to the baseline when absent. */
37
46
  compatibilityDate?: string;
47
+ /** #15 — which `__sbCall` to compile in. Defaults to the broker transport. */
48
+ transport?: Transport;
49
+ /** #15 — the project name, baked so an embedded build can default its data dir. */
50
+ appName?: string;
51
+ /** #15 — assets baked into the module (embedded builds have no files on disk). */
52
+ assets?: { manifest: unknown; files: Record<string, string> };
53
+ /** #15 — objects to add to the native-fetch link line (SQLite, BearSSL). */
54
+ extraLink?: string[];
55
+ /** #15 — flags for the compile step, so inline C can include BearSSL's header. */
56
+ extraCflags?: string[];
57
+ /**
58
+ * How hard the C compiler works on the generated code.
59
+ *
60
+ * Porffor defaults to `-O3` (compiler/index.js reads `-O` from its own argv),
61
+ * which costs most of the build: `examples/hello` is 8.4s at -O3 against 2.9s
62
+ * at -O0 on an M-series host. `dev` pays that on every edit for a binary that
63
+ * never leaves the machine, so it compiles at -O0 and takes a ~55% larger
64
+ * file. Anything that could be deployed stays at -O3.
65
+ */
66
+ optimize?: "dev" | "release";
38
67
  /** Cross-compiler for `linux-x86_64`. Not needed, and not used, for `host`. */
39
68
  zigBin?: string;
40
69
  /**
@@ -46,15 +75,65 @@ export type CompileInput = {
46
75
  target?: "linux-x86_64" | "host";
47
76
  };
48
77
 
78
+ /**
79
+ * The prelude with a transport spliced in — the exact text the compiler sees.
80
+ *
81
+ * Exported because more than one caller composes a sprout: the build, and the
82
+ * kitchen-sink harness that compiles one itself. Reading
83
+ * `native-fetch-prelude.js` alone yields a module with no `__sbCall` at all.
84
+ */
85
+ export async function loadPrelude(transport: Transport = "broker"): Promise<string> {
86
+ const [core, chosen] = await Promise.all([readFile(preludePath, "utf8"), readFile(transportPath(transport), "utf8")]);
87
+ if (!core.includes(TRANSPORT_MARKER)) {
88
+ throw new Error("prelude is missing its transport marker — src/native-fetch-prelude.js changed shape");
89
+ }
90
+ return core.replace(TRANSPORT_MARKER, chosen);
91
+ }
92
+
93
+ /** Child env for the Porffor run. `SB_EXTRA_LINK` is read by the patched link
94
+ * step (#15) and is absent entirely for a normal build. */
95
+ function compileEnv(path: string, extraLink?: string[], extraCflags?: string[]) {
96
+ const link = extraLink && extraLink.length > 0 ? extraLink.join(" ") : undefined;
97
+ const cflags = extraCflags && extraCflags.length > 0 ? extraCflags.join(" ") : undefined;
98
+ return { ...process.env, PATH: path, SB_EXTRA_LINK: link, SB_EXTRA_CFLAGS: cflags };
99
+ }
100
+
101
+ /**
102
+ * The argument list handed to Porffor, minus the launcher.
103
+ *
104
+ * Exported for the test: the one thing worth pinning is that `-O0` reaches only
105
+ * a build that cannot be deployed. `-s` strips at link, because the unstripped
106
+ * static-musl binary is ~90% DWARF that nothing needs at runtime (12 MB down to
107
+ * ~1.3 MB for the kitchen-sink). `--musl` is what makes it a cross-compile; a
108
+ * host build omits it and Porffor targets the machine it runs on.
109
+ */
110
+ export function porfforArgs(
111
+ generatedPath: string,
112
+ outPath: string,
113
+ target: CompileInput["target"],
114
+ optimize: CompileInput["optimize"],
115
+ ): string[] {
116
+ const args = ["native", generatedPath, "-o", outPath];
117
+ if (target !== "host") args.push("--musl");
118
+ args.push("-s");
119
+ // Porffor reads -O from its own argv and defaults to -O3. -O0 is roughly
120
+ // three times faster to compile, so dev takes it and everything else does
121
+ // not: a deployable artifact is never built with it.
122
+ if (optimize === "dev" && target === "host") args.push("-O0");
123
+ return args;
124
+ }
125
+
49
126
  /** Compile `sourcePath` to a native binary at `outPath` (mode 0555). */
50
127
  export async function compileSprout(input: CompileInput): Promise<void> {
51
128
  await ensurePorfforPatched();
52
129
 
53
130
  // Seed the Porffor uWebSockets cache from the prebuilt archive in `vendor/` so
54
- // the first build needs no `git` / `make`. Fall back to Porffor's own git+make
55
- // path if the archive is missing or fails its checksum.
131
+ // the first build needs no `git` / `make`. The two targets keep separate
132
+ // cache trees and separate uSockets.a, so seed whichever this build wants.
133
+ // Fall back to Porffor's own git+make path if the archive is unusable.
56
134
  try {
57
- await ensureUWebSockets();
135
+ if (input.target === "host") await ensureUWebSocketsHost();
136
+ else await ensureUWebSockets();
58
137
  } catch (error) {
59
138
  if (!(error instanceof UwsUnavailableError)) throw error;
60
139
  const haveGit = Bun.which("git");
@@ -83,7 +162,7 @@ export async function compileSprout(input: CompileInput): Promise<void> {
83
162
  const generatedPath = resolve(outDir, "sprout.generated.js");
84
163
  const [source, prelude] = await Promise.all([
85
164
  input.source === undefined ? readFile(input.sourcePath, "utf8") : Promise.resolve(input.source),
86
- readFile(preludePath, "utf8"),
165
+ loadPrelude(input.transport ?? "broker"),
87
166
  ]);
88
167
  await writeFile(
89
168
  generatedPath,
@@ -94,6 +173,8 @@ export async function compileSprout(input: CompileInput): Promise<void> {
94
173
  input.bindings ?? EMPTY_BINDINGS,
95
174
  undefined,
96
175
  input.compatibilityDate,
176
+ input.appName,
177
+ input.assets,
97
178
  ),
98
179
  );
99
180
 
@@ -105,15 +186,14 @@ export async function compileSprout(input: CompileInput): Promise<void> {
105
186
  const zigDir = input.zigBin ? `${dirname(input.zigBin)}:` : "";
106
187
  const path = `${zigDir}${binDir}:${process.env.PATH ?? ""}`;
107
188
 
108
- // `-s`: strip at link. The unstripped static-musl binary is ~90% DWARF that
109
- // nothing needs at runtime (12 MB -> ~1.3 MB for the kitchen-sink). Porffor
110
- // forwards `-s` straight to the `zig cc` link step.
111
- // `--musl` is what makes it a cross-compile; a host build simply omits it and
112
- // Porffor targets the machine it is running on.
113
- const crossFlags = input.target === "host" ? [] : ["--musl"];
114
189
  const child = Bun.spawn(
115
- [process.execPath, launcher, "native", generatedPath, "-o", input.outPath, ...crossFlags, "-s"],
116
- { cwd: outDir, stdout: "pipe", stderr: "pipe", env: { ...process.env, PATH: path } },
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
+ },
117
197
  );
118
198
  let timedOut = false;
119
199
  const timer = setTimeout(() => {
package/src/config.ts CHANGED
@@ -58,6 +58,9 @@ export type SproutboatConfig = {
58
58
  analytics_engine_datasets?: string[];
59
59
  /** Durable Object bindings: `{ BINDING_NAME: "ClassName" }`. The class is defined in the handler module. */
60
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 }>;
61
64
  /** Scheduled triggers, e.g. `{ "crons": ["0 3 * * *"] }` — a `scheduled(event)` handler runs on each tick. */
62
65
  triggers?: { crons?: string[] };
63
66
  /** Static assets: a directory served edge-first (like Cloudflare), optionally bound as `env.<BINDING>.fetch(request)`. */
@@ -114,6 +117,7 @@ function validateConfig(value: ConfigInput): ConfigValidation {
114
117
  "queues",
115
118
  "analytics_engine_datasets",
116
119
  "durable_objects",
120
+ "services",
117
121
  "triggers",
118
122
  "assets",
119
123
  ]);
@@ -233,6 +237,25 @@ function validateConfig(value: ConfigInput): ConfigValidation {
233
237
  }
234
238
  }
235
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
+
236
259
  let triggers: { crons?: string[] } | undefined;
237
260
  if (value.triggers !== undefined) {
238
261
  if (!isRecord(value.triggers)) {
@@ -301,6 +324,7 @@ function validateConfig(value: ConfigInput): ConfigValidation {
301
324
  ...resourceNames(queues),
302
325
  ...(analytics_engine_datasets ?? []),
303
326
  ...Object.keys(durable_objects ?? {}),
327
+ ...(services ?? []).map((entry) => entry.binding),
304
328
  ...Object.keys(vars ?? {}),
305
329
  ...(assets?.binding ? [assets.binding] : []),
306
330
  ];
@@ -319,6 +343,7 @@ function validateConfig(value: ConfigInput): ConfigValidation {
319
343
  if ("queues" in value) config.queues = queues;
320
344
  if ("analytics_engine_datasets" in value) config.analytics_engine_datasets = analytics_engine_datasets;
321
345
  if ("durable_objects" in value) config.durable_objects = durable_objects;
346
+ if ("services" in value) config.services = services;
322
347
  if ("triggers" in value) config.triggers = triggers;
323
348
  if ("assets" in value) config.assets = assets;
324
349
  return { ok: true, value: config };
package/src/dev.ts CHANGED
@@ -85,6 +85,8 @@ async function start(input: DevInput, source: string): Promise<Running> {
85
85
  sourcePath: input.sourcePath,
86
86
  source,
87
87
  target: "host",
88
+ // Never deployable, rebuilt on every edit: buy the iteration loop.
89
+ optimize: "dev",
88
90
  });
89
91
  const artifactDir = artifact.artifactDir;
90
92
  const sproutPath = resolve(artifactDir, "sprout");
package/src/main.ts CHANGED
@@ -1,13 +1,16 @@
1
1
  #!/usr/bin/env bun
2
- import { mkdir, readFile, writeFile } from "node:fs/promises";
3
- import { basename, resolve } from "node:path";
2
+ import { mkdir, open, readFile, rename, writeFile } from "node:fs/promises";
3
+ import { rmSync } from "node:fs";
4
+ import { basename, dirname, relative, resolve } from "node:path";
4
5
  import { isBoolean, isSafeInteger, isString, jsonObject, parseJsonValue, type JsonObject } from "./json";
5
6
  import type { AssetFiles } from "./assets";
6
7
  import { parseConfig, pinBindingId, resourceRefs, type SproutboatConfig } from "./config";
7
8
  import { validateHttpSyncSource } from "./source";
8
9
  import { buildArtifact } from "./build";
10
+ import { generateTypes, TYPES_FILE } from "./types-gen";
9
11
  import { bundleHandler, BundleError, type BundleResult } from "./bundle";
10
12
  import { runDev } from "./dev";
13
+ import { buildStandalone } from "./standalone-build";
11
14
  import { hostTarget, validateManifest, type ArtifactManifest } from "./manifest";
12
15
  import { CLI_VERSION, printDeployReport } from "./report";
13
16
  import { activeApiUrl, forgetToken, savedToken, saveToken } from "./credentials";
@@ -230,6 +233,35 @@ async function check(directory?: string) {
230
233
  console.log(ok(`check passed — ${project.config.name} (${project.config.main}, native-fetch)`));
231
234
  }
232
235
 
236
+ async function types(directory?: string) {
237
+ const project = await readProject(directory);
238
+ const path = resolve(project.directory, TYPES_FILE);
239
+ await writeFile(path, generateTypes(project.config));
240
+ console.log(ok(`wrote ${relative(process.cwd(), path) || TYPES_FILE}`));
241
+ console.log(dim(" Commit it. Add it to tsconfig `include` if your project has one."));
242
+ }
243
+
244
+ /**
245
+ * Rewrite the generated types when the config has moved on, and only then.
246
+ *
247
+ * Called from `dev` and `deploy` so a binding added to sproutboat.jsonc cannot
248
+ * sit unreflected in the editor. It writes only when the content actually
249
+ * differs, which keeps it out of the way of git and of file watchers.
250
+ */
251
+ async function refreshTypes(projectDir: string, config: SproutboatConfig): Promise<void> {
252
+ const path = resolve(projectDir, TYPES_FILE);
253
+ const wanted = generateTypes(config);
254
+ try {
255
+ if ((await readFile(path, "utf8")) === wanted) return;
256
+ } catch {
257
+ // No file yet: only write one if the project already opted in by having a
258
+ // tsconfig. Generating into a plain JavaScript project is noise.
259
+ if (!(await Bun.file(resolve(projectDir, "tsconfig.json")).exists())) return;
260
+ }
261
+ await writeFile(path, wanted);
262
+ console.log(dim(` ${TYPES_FILE} refreshed from sproutboat.jsonc`));
263
+ }
264
+
233
265
  async function build(directory?: string, target: "linux-x86_64" | "host" = "linux-x86_64") {
234
266
  const project = await readProject(directory);
235
267
  console.log(
@@ -251,6 +283,32 @@ async function build(directory?: string, target: "linux-x86_64" | "host" = "linu
251
283
  return { project, artifact };
252
284
  }
253
285
 
286
+ /**
287
+ * #15 — one executable carrying the sprout, its assets and its bindings.
288
+ *
289
+ * Defaults to a host build: the point is a binary you can run right here. Pass
290
+ * `--target linux-x86_64` for a box, but note the Bun launcher is compiled for
291
+ * *this* machine either way in phase 0 — cross-compiling both halves comes with
292
+ * the embedded backend, which removes the launcher entirely.
293
+ */
294
+ async function buildStandaloneBinary(directory: string | undefined, target: "linux-x86_64" | "host") {
295
+ const project = await readProject(directory);
296
+ console.log(dim(`Building a standalone ${project.config.name} (${target})…`));
297
+ try {
298
+ const result = await buildStandalone({
299
+ projectDir: project.directory,
300
+ config: project.config,
301
+ sourcePath: project.sourcePath,
302
+ source: project.bundle.code,
303
+ target,
304
+ });
305
+ console.log(ok(`built ${result.outPath} (${(result.bytes / 1_000_000).toFixed(1)} MB)`));
306
+ console.log(dim(` run it: ${result.outPath} · state lands in ./${project.config.name}.data`));
307
+ } catch (error) {
308
+ fail(error instanceof Error ? error.message : String(error));
309
+ }
310
+ }
311
+
254
312
  /** #62 — build for this machine, run it against a real broker, rebuild on save. */
255
313
  async function dev(args: string[]) {
256
314
  const directory = args.find((arg) => !arg.startsWith("--") && !/^\d+$/.test(arg));
@@ -260,6 +318,7 @@ async function dev(args: string[]) {
260
318
  if (!Number.isSafeInteger(port) || port < 1 || port > 65_535)
261
319
  usageError(`invalid --port: ${portArg}`, "dev [project-dir] [--port <n>] [--no-watch]");
262
320
  const project = await readProject(directory);
321
+ await refreshTypes(project.directory, project.config);
263
322
  console.log(dim(`Building ${project.config.name} for this machine (${hostTarget()})…`));
264
323
  await runDev({
265
324
  projectDir: project.directory,
@@ -383,6 +442,8 @@ async function deploy(args: string[]) {
383
442
  projectName = built.project.config.name;
384
443
  artifactDir = built.artifact.artifactDir;
385
444
  config = built.project.config;
445
+ // After provisioning: ids may have been pinned into the config just now.
446
+ await refreshTypes(built.project.directory, built.project.config);
386
447
  }
387
448
  const manifest = Bun.file(resolve(artifactDir, "manifest.json"));
388
449
  const sprout = Bun.file(resolve(artifactDir, "sprout"));
@@ -870,6 +931,194 @@ async function storage(key: string, args: string[]) {
870
931
  console.log(ok(`deleted ${product.noun} ${name}`));
871
932
  }
872
933
 
934
+ function option(args: string[], name: string): string | undefined {
935
+ const index = args.indexOf(name);
936
+ return index >= 0 ? args[index + 1] : undefined;
937
+ }
938
+
939
+ async function writeAtomic(
940
+ path: string,
941
+ chunks: AsyncIterable<string> | readonly string[],
942
+ overwrite: boolean,
943
+ ): Promise<void> {
944
+ const destination = resolve(path);
945
+ if (!overwrite && (await Bun.file(destination).exists())) fail(`${path} already exists; pass --force to replace it`);
946
+ await mkdir(dirname(destination), { recursive: true });
947
+ const temporary = `${destination}.tmp-${process.pid}-${Date.now()}`;
948
+ const cleanup = () => {
949
+ try {
950
+ rmSync(temporary, { force: true });
951
+ } catch {
952
+ /* best effort during process shutdown */
953
+ }
954
+ };
955
+ process.once("exit", cleanup);
956
+ const file = await open(temporary, "wx", 0o600);
957
+ try {
958
+ for await (const chunk of chunks) await file.write(chunk);
959
+ await file.sync();
960
+ await file.close();
961
+ await rename(temporary, destination);
962
+ process.off("exit", cleanup);
963
+ } catch (error) {
964
+ await file.close().catch(() => undefined);
965
+ cleanup();
966
+ process.off("exit", cleanup);
967
+ throw error;
968
+ }
969
+ }
970
+
971
+ async function kvContents(args: string[]): Promise<void> {
972
+ const area = args.shift();
973
+ if (!area || !["key", "bulk", "export"].includes(area))
974
+ usageError("kv: unknown contents command", "kv <key | bulk | export> ...");
975
+ const namespace = area === "export" ? args.shift() : args[1];
976
+ if (!namespace) usageError(`kv ${area}: missing <namespace>`, `kv ${area} <namespace> ...`);
977
+ const { apiUrl, token } = await apiCredentials();
978
+ const rows = await storageRows(`${apiUrl}/api/kv`, { "x-api-key": token }, STORAGE_PRODUCTS[0]);
979
+ const id = idForName(rows, namespace, STORAGE_PRODUCTS[0]);
980
+ const base = `${apiUrl}/api/kv/${id}`;
981
+ const auth = { "x-api-key": token };
982
+
983
+ if (area === "key") {
984
+ const verb = args.shift();
985
+ args.shift();
986
+ if (!verb || !["list", "get", "put", "delete"].includes(verb))
987
+ usageError("kv key: unknown operation", "kv key <list | get | put | delete> <namespace> ...");
988
+ if (verb === "list") {
989
+ const query = new URLSearchParams();
990
+ for (const flag of ["--prefix", "--cursor", "--limit"] as const) {
991
+ const value = option(args, flag);
992
+ if (value !== undefined) query.set(flag.slice(2), value);
993
+ }
994
+ const body = await responseText(await fetch(`${base}/keys?${query}`, { headers: auth }), "list rejected");
995
+ console.log(body);
996
+ return;
997
+ }
998
+ const key = args.shift();
999
+ if (!key) usageError(`kv key ${verb}: missing <key>`, `kv key ${verb} <namespace> <key>`);
1000
+ const url = `${base}/keys/${encodeURIComponent(key)}`;
1001
+ if (verb === "get") {
1002
+ const body = await responseText(await fetch(url, { headers: auth }), "get rejected");
1003
+ const record = jsonObject(parseJsonValue(body));
1004
+ if (!record || !isString(record.value)) fail("get response did not contain a value");
1005
+ const output = option(args, "--output");
1006
+ if (output) await writeAtomic(output, [record.value], args.includes("--force"));
1007
+ else console.log(args.includes("--text") ? record.value : JSON.stringify({ key, value: record.value }, null, 2));
1008
+ return;
1009
+ }
1010
+ if (verb === "put") {
1011
+ const path = option(args, "--path");
1012
+ const positional = args.find((arg, index) => !arg.startsWith("-") && args[index - 1] !== "--path");
1013
+ if (path && positional)
1014
+ usageError(
1015
+ "kv key put: use a value or --path, not both",
1016
+ "kv key put <namespace> <key> [value] [--path <file>]",
1017
+ );
1018
+ const value = path ? await readFile(resolve(path), "utf8") : (positional ?? (await Bun.stdin.text()));
1019
+ await responseText(
1020
+ await fetch(url, {
1021
+ method: "PUT",
1022
+ headers: { ...auth, "content-type": "application/json" },
1023
+ body: JSON.stringify({ value }),
1024
+ }),
1025
+ "put rejected",
1026
+ );
1027
+ console.log(ok(`set ${key}`));
1028
+ return;
1029
+ }
1030
+ if (!args.includes("--yes")) fail(`this permanently deletes "${key}"; re-run with --yes`);
1031
+ await responseText(await fetch(url, { method: "DELETE", headers: auth }), "delete rejected");
1032
+ console.log(ok(`deleted ${key}`));
1033
+ return;
1034
+ }
1035
+
1036
+ if (area === "bulk") {
1037
+ const verb = args.shift();
1038
+ args.shift();
1039
+ const input = args.shift();
1040
+ if (!verb || !["get", "put", "delete"].includes(verb) || !input)
1041
+ usageError(
1042
+ "kv bulk: expected an operation, namespace, and JSON file",
1043
+ "kv bulk <get | put | delete> <namespace> <file>",
1044
+ );
1045
+ if (verb === "delete" && !args.includes("--yes")) fail("bulk deletion requires --yes");
1046
+ const parsed = parseJsonValue(await readFile(resolve(input), "utf8"));
1047
+ if (!Array.isArray(parsed) || parsed.length === 0) fail("bulk input must be a non-empty JSON array");
1048
+ const responses: unknown[] = [];
1049
+ let changed = 0;
1050
+ const failures: unknown[] = [];
1051
+ for (let start = 0; start < parsed.length; start += 100) {
1052
+ const body = await responseText(
1053
+ await fetch(`${base}/bulk/${verb}`, {
1054
+ method: "POST",
1055
+ headers: { ...auth, "content-type": "application/json" },
1056
+ body: JSON.stringify(parsed.slice(start, start + 100)),
1057
+ }),
1058
+ `bulk ${verb} rejected`,
1059
+ );
1060
+ const result = parseJsonValue(body);
1061
+ if (verb === "get" && Array.isArray(result)) responses.push(...result);
1062
+ else {
1063
+ const summary = jsonObject(result);
1064
+ changed += Number(summary?.written ?? summary?.deleted ?? 0);
1065
+ if (Array.isArray(summary?.failures)) failures.push(...summary.failures);
1066
+ }
1067
+ }
1068
+ const result = verb === "get" ? responses : { [verb === "put" ? "written" : "deleted"]: changed, failures };
1069
+ const output = option(args, "--output");
1070
+ if (output) await writeAtomic(output, [JSON.stringify(result, null, 2) + "\n"], args.includes("--force"));
1071
+ else console.log(JSON.stringify(result, null, 2));
1072
+ return;
1073
+ }
1074
+
1075
+ const output = option(args, "--output");
1076
+ if (!output)
1077
+ usageError(
1078
+ "kv export: missing --output <file>",
1079
+ "kv export <namespace> --output <dump.json> [--prefix <prefix>] [--force]",
1080
+ );
1081
+ const prefix = option(args, "--prefix") ?? "";
1082
+ async function* dump(): AsyncGenerator<string> {
1083
+ yield "[\n";
1084
+ let cursor = "";
1085
+ let first = true;
1086
+ do {
1087
+ const query = new URLSearchParams({ prefix, limit: "100" });
1088
+ if (cursor) query.set("cursor", cursor);
1089
+ const listed = jsonObject(
1090
+ parseJsonValue(
1091
+ await responseText(await fetch(`${base}/keys?${query}`, { headers: auth }), "export list rejected"),
1092
+ ),
1093
+ );
1094
+ const keys = Array.isArray(listed?.keys) ? listed.keys.filter(isString) : [];
1095
+ if (keys.length) {
1096
+ const values = parseJsonValue(
1097
+ await responseText(
1098
+ await fetch(`${base}/bulk/get`, {
1099
+ method: "POST",
1100
+ headers: { ...auth, "content-type": "application/json" },
1101
+ body: JSON.stringify(keys),
1102
+ }),
1103
+ "export read rejected",
1104
+ ),
1105
+ );
1106
+ if (!Array.isArray(values)) fail("export response was not an array");
1107
+ for (const entry of values) {
1108
+ const record = jsonObject(entry);
1109
+ if (!record || !isString(record.key) || !isString(record.value)) continue;
1110
+ yield `${first ? "" : ",\n"} ${JSON.stringify({ key: record.key, value: record.value })}`;
1111
+ first = false;
1112
+ }
1113
+ }
1114
+ cursor = isString(listed?.cursor) ? listed.cursor : "";
1115
+ } while (cursor);
1116
+ yield "\n]\n";
1117
+ }
1118
+ await writeAtomic(output, dump(), args.includes("--force"));
1119
+ console.log(ok(`exported ${namespace} to ${output}`));
1120
+ }
1121
+
873
1122
  async function deleteProject(args: string[]) {
874
1123
  // `sproutboat delete [project-dir] [--name <project>] --yes` — flags in any order.
875
1124
  const positional: string[] = [];
@@ -936,15 +1185,17 @@ switch (command) {
936
1185
  case "check":
937
1186
  await check(args[0]);
938
1187
  break;
1188
+ case "types":
1189
+ await types(args[0]);
1190
+ break;
939
1191
  case "dev":
940
1192
  await dev(args);
941
1193
  break;
942
1194
  case "build": {
943
1195
  const hostBuild = args.includes("--target") && args[args.indexOf("--target") + 1] === "host";
944
- await build(
945
- args.find((arg) => !arg.startsWith("--") && arg !== "host"),
946
- hostBuild ? "host" : "linux-x86_64",
947
- );
1196
+ const directory = args.find((arg) => !arg.startsWith("--") && arg !== "host");
1197
+ if (args.includes("--standalone")) await buildStandaloneBinary(directory, hostBuild ? "host" : "linux-x86_64");
1198
+ else await build(directory, hostBuild ? "host" : "linux-x86_64");
948
1199
  break;
949
1200
  }
950
1201
  case "login":
@@ -972,6 +1223,9 @@ switch (command) {
972
1223
  await secrets(args);
973
1224
  break;
974
1225
  case "kv":
1226
+ if (["key", "bulk", "export"].includes(args[0] ?? "")) await kvContents(args);
1227
+ else await storage(command, args);
1228
+ break;
975
1229
  case "d1":
976
1230
  case "r2":
977
1231
  case "queues":