sproutboat 0.8.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/main.ts CHANGED
@@ -1,11 +1,13 @@
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";
11
13
  import { buildStandalone } from "./standalone-build";
@@ -231,6 +233,35 @@ async function check(directory?: string) {
231
233
  console.log(ok(`check passed — ${project.config.name} (${project.config.main}, native-fetch)`));
232
234
  }
233
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
+
234
265
  async function build(directory?: string, target: "linux-x86_64" | "host" = "linux-x86_64") {
235
266
  const project = await readProject(directory);
236
267
  console.log(
@@ -287,6 +318,7 @@ async function dev(args: string[]) {
287
318
  if (!Number.isSafeInteger(port) || port < 1 || port > 65_535)
288
319
  usageError(`invalid --port: ${portArg}`, "dev [project-dir] [--port <n>] [--no-watch]");
289
320
  const project = await readProject(directory);
321
+ await refreshTypes(project.directory, project.config);
290
322
  console.log(dim(`Building ${project.config.name} for this machine (${hostTarget()})…`));
291
323
  await runDev({
292
324
  projectDir: project.directory,
@@ -410,6 +442,8 @@ async function deploy(args: string[]) {
410
442
  projectName = built.project.config.name;
411
443
  artifactDir = built.artifact.artifactDir;
412
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);
413
447
  }
414
448
  const manifest = Bun.file(resolve(artifactDir, "manifest.json"));
415
449
  const sprout = Bun.file(resolve(artifactDir, "sprout"));
@@ -897,6 +931,194 @@ async function storage(key: string, args: string[]) {
897
931
  console.log(ok(`deleted ${product.noun} ${name}`));
898
932
  }
899
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
+
900
1122
  async function deleteProject(args: string[]) {
901
1123
  // `sproutboat delete [project-dir] [--name <project>] --yes` — flags in any order.
902
1124
  const positional: string[] = [];
@@ -963,6 +1185,9 @@ switch (command) {
963
1185
  case "check":
964
1186
  await check(args[0]);
965
1187
  break;
1188
+ case "types":
1189
+ await types(args[0]);
1190
+ break;
966
1191
  case "dev":
967
1192
  await dev(args);
968
1193
  break;
@@ -998,6 +1223,9 @@ switch (command) {
998
1223
  await secrets(args);
999
1224
  break;
1000
1225
  case "kv":
1226
+ if (["key", "bulk", "export"].includes(args[0] ?? "")) await kvContents(args);
1227
+ else await storage(command, args);
1228
+ break;
1001
1229
  case "d1":
1002
1230
  case "r2":
1003
1231
  case "queues":
@@ -587,8 +587,8 @@ globalThis.__sbInstallBindings = function (target, bindings) {
587
587
 
588
588
  // Static assets: env.<ASSETS>.fetch(request) -> broker `assets.get`. The edge
589
589
  // already serves matching files directly; the sprout only calls this for paths
590
- // it wants to own (SPA fallback, auth-gated files). Text assets only — binary
591
- // files go through the edge (the broker frame is UTF-8 JSON).
590
+ // it wants to own (SPA fallback, auth-gated files). The transport keeps the
591
+ // body byte-preserving for binary assets.
592
592
  if (bindings.assets) {
593
593
  target[bindings.assets] = {
594
594
  fetch(input) {
@@ -598,7 +598,7 @@ globalThis.__sbInstallBindings = function (target, bindings) {
598
598
  } catch {
599
599
  /* use as-is */
600
600
  }
601
- const r = __sbRpc("assets.get", { path });
601
+ const r = globalThis.__sbAssetsGet(path);
602
602
  const headers = {};
603
603
  if (r.type) headers["content-type"] = r.type;
604
604
  if (r.found) headers["etag"] = '"' + r.hash + '"';
package/src/surface.ts CHANGED
@@ -57,9 +57,9 @@ const storageCommands: readonly Command[] = STORAGE_PRODUCTS.map((product) => ({
57
57
  name: product.name,
58
58
  group: "Storage" as const,
59
59
  emoji: product.emoji,
60
- args: STORAGE_ARGS,
60
+ args: product.name === "kv" ? `${STORAGE_ARGS} | <key | bulk | export> ...` : STORAGE_ARGS,
61
61
  brief: `<${STORAGE_VERBS.join(" | ")}>`,
62
- summary: `${product.plural[0].toUpperCase()}${product.plural.slice(1)}. \`create\` prints the id to bind from sproutboat.jsonc${product.name === "queues" ? "; consumers deliver in batches with retries, and stop after 5 attempts" : ""}.`,
62
+ summary: `${product.plural[0].toUpperCase()}${product.plural.slice(1)}. \`create\` prints the id to bind from sproutboat.jsonc${product.name === "kv" ? "; key and bulk operations manage contents, and export writes a restorable JSON dump" : ""}${product.name === "queues" ? "; consumers deliver in batches with retries, and stop after 5 attempts" : ""}.`,
63
63
  }));
64
64
 
65
65
  export const COMMANDS: readonly Command[] = [
@@ -77,6 +77,14 @@ export const COMMANDS: readonly Command[] = [
77
77
  args: "[project-dir]",
78
78
  summary: "Validate the config and entry point without building.",
79
79
  },
80
+ {
81
+ name: "types",
82
+ group: "Develop",
83
+ emoji: "🧾",
84
+ args: "[project-dir]",
85
+ summary:
86
+ "Write sproutboat-env.d.ts from the config, so an editor knows what `env` holds. `dev` and `deploy` refresh it when the config is newer.",
87
+ },
80
88
  {
81
89
  name: "dev",
82
90
  group: "Develop",
@@ -189,8 +197,14 @@ export const ENV_VARS: readonly EnvVar[] = [
189
197
  {
190
198
  name: "SPROUTBOAT_UWS_TARBALL",
191
199
  purpose:
192
- "Path to a prebuilt uWebSockets (x86_64-linux-musl) tarball to seed the Porffor cache with, instead of downloading it (removes the first-build git + make need).",
200
+ "Path to a uWebSockets source tarball to seed the Porffor cache with, instead of the one vendored in the package. Both targets are seeded from it.",
201
+ },
202
+ {
203
+ name: "CC",
204
+ purpose:
205
+ "C compiler used to build uSockets for a host build (default `cc`). A host build needs one regardless: it is what Porffor compiles its own generated C with.",
193
206
  },
207
+ { name: "AR", purpose: "Archiver used to assemble uSockets.a for a host build (default `ar`)." },
194
208
  { name: "SPROUTBOAT_COMPILE_TIMEOUT_MS", purpose: "Porffor compile timeout in ms (default 600000)." },
195
209
  {
196
210
  name: "SPROUTBOAT_VARS_JSON",
package/src/toolchain.ts CHANGED
@@ -156,6 +156,11 @@ export async function ensureUWebSockets(): Promise<void> {
156
156
  }
157
157
  }
158
158
 
159
+ await extractUws(archive, dir);
160
+ }
161
+
162
+ /** Unpack the vendored source tree into `dir`. */
163
+ async function extractUws(archive: string, dir: string): Promise<void> {
159
164
  await mkdir(dir, { recursive: true });
160
165
  const untar = Bun.spawn(["tar", "-xJf", archive, "-C", dir, "--strip-components=1"], {
161
166
  stdout: "pipe",
@@ -168,6 +173,72 @@ export async function ensureUWebSockets(): Promise<void> {
168
173
  }
169
174
  }
170
175
 
176
+ /**
177
+ * The same seeding for a **host** build (`dev`, `build --target host`,
178
+ * `--standalone` on this machine).
179
+ *
180
+ * The vendored archive is a full checkout, so the sources are the same for
181
+ * every target; only `uSockets.a` is target-specific. Porffor would build that
182
+ * with `make -C uSockets`, which is where the `git` and `make` requirements on
183
+ * a first build came from. Compiling the thirteen C files directly removes
184
+ * both, and adds no new dependency: a host build already needs a C compiler,
185
+ * because that is what Porffor compiles its own generated C with.
186
+ *
187
+ * Flags mirror the uSockets makefile's default target with the WITH_* switches
188
+ * Porffor passes (all off): `-std=c11 -Isrc -DLIBUS_NO_SSL -flto -O3`, then
189
+ * `ar rvs`. Nothing here is a judgement call; it is that recipe.
190
+ */
191
+ export async function ensureUWebSocketsHost(): Promise<void> {
192
+ const commit = uwsCommitFull();
193
+ const dir = resolve(homedir(), ".cache/porffor/deps", `uWebSockets-${commit}`);
194
+ const uSockets = resolve(dir, "uSockets");
195
+ const archivePath = resolve(uSockets, "uSockets.a");
196
+ if (existsSync(resolve(dir, "src/App.h")) && existsSync(archivePath)) return;
197
+
198
+ const vendored = process.env.SPROUTBOAT_UWS_TARBALL || uwsVendorArchive(commit.slice(0, 8));
199
+ if (!existsSync(vendored)) {
200
+ throw new UwsUnavailableError(`no vendored uWebSockets archive at ${vendored}`);
201
+ }
202
+ if (!process.env.SPROUTBOAT_UWS_TARBALL) {
203
+ const actual = await sha256File(vendored);
204
+ if (actual !== UWS_TARBALL_SHA256) {
205
+ throw new UwsUnavailableError(
206
+ `vendored uWebSockets sha256 mismatch\n expected ${UWS_TARBALL_SHA256}\n got ${actual}`,
207
+ );
208
+ }
209
+ }
210
+ if (!existsSync(resolve(dir, "src/App.h"))) await extractUws(vendored, dir);
211
+
212
+ // The archive carries the musl-built uSockets.a. Linking that into a host
213
+ // binary fails in a way nobody would connect to this, so it goes first.
214
+ await rm(archivePath, { force: true });
215
+
216
+ const sources = ["src/*.c", "src/eventing/*.c", "src/crypto/*.c", "src/io_uring/*.c"];
217
+ const cc = process.env.CC || "cc";
218
+ const ar = process.env.AR || "ar";
219
+ const compile = Bun.spawn(["sh", "-c", `${cc} -std=c11 -Isrc -DLIBUS_NO_SSL -flto -O3 -c ${sources.join(" ")}`], {
220
+ cwd: uSockets,
221
+ stdout: "pipe",
222
+ stderr: "pipe",
223
+ });
224
+ const [ccCode, ccErr] = await Promise.all([compile.exited, new Response(compile.stderr).text()]);
225
+ if (ccCode !== 0) {
226
+ throw new UwsUnavailableError(
227
+ `could not compile uSockets with ${cc}: ${ccErr.trim().split("\n").slice(-3).join(" ")}`,
228
+ );
229
+ }
230
+
231
+ const archiveStep = Bun.spawn(["sh", "-c", `${ar} rvs uSockets.a *.o`], {
232
+ cwd: uSockets,
233
+ stdout: "pipe",
234
+ stderr: "pipe",
235
+ });
236
+ const [arCode, arErr] = await Promise.all([archiveStep.exited, new Response(archiveStep.stderr).text()]);
237
+ if (arCode !== 0 || !existsSync(archivePath)) {
238
+ throw new UwsUnavailableError(`could not archive uSockets with ${ar}: ${arErr.trim()}`);
239
+ }
240
+ }
241
+
171
242
  /** Directory holding node_modules/porffor (walks up from this file). */
172
243
  export function porfforRoot(start = import.meta.dir): string {
173
244
  let dir = start;
@@ -288,3 +288,12 @@ globalThis.__sbR2Get = function (bucket, key) {
288
288
  if (!reply.found) return { found: false };
289
289
  return { found: true, object: reply.object, body: __sbTakeBin() };
290
290
  };
291
+
292
+ /** Static asset metadata stays JSON; the response body uses the v1 byte tail. */
293
+ globalThis.__sbAssetsGet = function (path) {
294
+ const token = __sbEnv("SB_BROKER_TOKEN");
295
+ const reply = JSON.parse(__sbCallBin(JSON.stringify({ v: 1, token, op: "assets.get", path }), ""));
296
+ if (reply.ok === false) throw new Error("sproutboat assets.get: " + (reply.error || "failed"));
297
+ reply.body = __sbTakeBin();
298
+ return reply;
299
+ };
@@ -1413,6 +1413,10 @@ globalThis.__sbR2Get = function (bucket, key) {
1413
1413
  return { found: true, object: meta.object, body: __sbR2GetRaw(__sbStore(), String(bucket), String(key)) };
1414
1414
  };
1415
1415
 
1416
+ globalThis.__sbAssetsGet = function (path) {
1417
+ return __sbEmbeddedDispatch({ op: "assets.get", path });
1418
+ };
1419
+
1416
1420
  /** The transport contract: one request string in, one reply string out. */
1417
1421
  function __sbCall(reqJson) {
1418
1422
  try {
@@ -0,0 +1,90 @@
1
+ /**
2
+ * `sproutboat types` — the project's `env`, as TypeScript.
3
+ *
4
+ * In Workers `env` is a parameter, so an editor can infer it from a signature.
5
+ * Here it is a global, which means nothing is inferable at all: `env.SESSIONS`
6
+ * is an undeclared identifier until this file exists. That makes generated
7
+ * types worth more here than they are there, not less.
8
+ *
9
+ * The output declares `env` from `sproutboat.jsonc` and references the ambient
10
+ * types shipped in `types/sproutboat.d.ts`, so binding *shapes* version with
11
+ * the CLI and binding *names* version with the project.
12
+ *
13
+ * Names need no quoting: `parseConfig` already holds every binding and var to
14
+ * /^[A-Z][A-Z0-9_]*$/, so all of them are plain identifiers.
15
+ */
16
+ import { resourceRefs, type SproutboatConfig } from "./config";
17
+
18
+ /** The file the generator writes, relative to the project directory. */
19
+ export const TYPES_FILE = "sproutboat-env.d.ts";
20
+
21
+ const HEADER = `// Generated by \`sproutboat types\`. Do not edit.
22
+ //
23
+ // Regenerated whenever sproutboat.jsonc is newer than this file, so a binding
24
+ // added to the config shows up in the editor without asking. Commit it: it is
25
+ // part of the project's type surface, like a schema.
26
+ `;
27
+
28
+ type Entry = { name: string; type: string; note?: string };
29
+
30
+ function entries(config: SproutboatConfig): Entry[] {
31
+ const out: Entry[] = [];
32
+
33
+ for (const [name, value] of Object.entries(config.vars ?? {})) {
34
+ out.push({ name, type: "string", note: `vars: ${JSON.stringify(value)}` });
35
+ }
36
+ for (const name of config.secrets ?? []) {
37
+ out.push({ name, type: "string", note: "secret, set with `sproutboat secrets set`" });
38
+ }
39
+ for (const { binding } of resourceRefs(config.kv_namespaces)) out.push({ name: binding, type: "KVNamespace" });
40
+ for (const { binding } of resourceRefs(config.d1_databases)) out.push({ name: binding, type: "D1Database" });
41
+ for (const { binding } of resourceRefs(config.r2_buckets)) out.push({ name: binding, type: "R2Bucket" });
42
+ for (const { binding } of resourceRefs(config.queues)) out.push({ name: binding, type: "Queue" });
43
+ for (const name of config.analytics_engine_datasets ?? []) {
44
+ out.push({ name, type: "AnalyticsEngineDataset" });
45
+ }
46
+ for (const [binding, className] of Object.entries(config.durable_objects ?? {})) {
47
+ out.push({ name: binding, type: "DurableObjectNamespace", note: `class ${className}` });
48
+ }
49
+ for (const service of config.services ?? []) {
50
+ out.push({ name: service.binding, type: "Fetcher", note: `service binding: ${service.service}` });
51
+ }
52
+ if (config.assets?.binding) {
53
+ out.push({ name: config.assets.binding, type: "Fetcher", note: `static assets: ${config.assets.directory}` });
54
+ }
55
+ return out;
56
+ }
57
+
58
+ /**
59
+ * The contents of `sproutboat-env.d.ts` for `config`.
60
+ *
61
+ * Pure, so the test can assert on the text and `dev` can compare it against
62
+ * what is already on disk without writing.
63
+ */
64
+ export function generateTypes(config: SproutboatConfig): string {
65
+ const rows = entries(config);
66
+ const body = rows.length
67
+ ? rows
68
+ .map((entry) => ` ${entry.note ? `/** ${entry.note} */\n ` : ""}${entry.name}: ${entry.type};`)
69
+ .join("\n")
70
+ : " // No bindings declared in sproutboat.jsonc yet.";
71
+
72
+ return `${HEADER}
73
+ /// <reference types="sproutboat/types" />
74
+
75
+ declare global {
76
+ /**
77
+ * Bindings from sproutboat.jsonc, for project "${config.name}".
78
+ *
79
+ * A global, not a parameter: the handler signature is \`fetch(request)\`.
80
+ * Every call on these is synchronous, so \`await\` is allowed and does
81
+ * nothing.
82
+ */
83
+ const env: {
84
+ ${body}
85
+ };
86
+ }
87
+
88
+ export {};
89
+ `;
90
+ }