sproutboat 0.6.0 → 0.7.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/SURFACE.md CHANGED
@@ -3,7 +3,7 @@
3
3
  > Generated by `src/surface.test.ts` from `src/surface.ts` + the pinned
4
4
  > toolchain constants. Do not edit by hand — run `UPDATE_SURFACE=1 bun test`.
5
5
 
6
- **Package:** `sproutboat` 0.6.0 · runs on Bun (use `bunx`, not `npx`)
6
+ **Package:** `sproutboat` 0.7.0 · runs on Bun (use `bunx`, not `npx`)
7
7
 
8
8
  ## Commands
9
9
 
@@ -20,7 +20,7 @@
20
20
  | `kv` | `<list | create <name> | info <name> | rename <name> <new> | delete <name>>` | KV namespaces. `create` prints the id to bind from sproutboat.jsonc. |
21
21
  | `d1` | `<list | create <name> | info <name> | rename <name> <new> | delete <name>>` | D1 databases. `create` prints the id to bind from sproutboat.jsonc. |
22
22
  | `r2` | `<list | create <name> | info <name> | rename <name> <new> | delete <name>>` | R2 buckets. `create` prints the id to bind from sproutboat.jsonc. |
23
- | `queues` | `<list | create <name> | info <name> | rename <name> <new> | delete <name>>` | Queues. `create` prints the id to bind from sproutboat.jsonc; consumers are not implemented yet. |
23
+ | `queues` | `<list | create <name> | info <name> | rename <name> <new> | delete <name>>` | Queues. `create` prints the id to bind from sproutboat.jsonc; consumers deliver in batches with retries, and stop after 5 attempts. |
24
24
  | `domains` | `<list | add <host> | verify <host> | delete <host>> [project-dir]` | Attach a custom domain to the project (TXT-verified). No sub-command lists. |
25
25
  | `secrets` | `<list | put <NAME> [--value <value>] | delete <NAME>> [project-dir]` | Manage encrypted project secrets (read as env.NAME). `put` reads the value from stdin unless --value is given, so it stays out of shell history; applies on next deploy. |
26
26
  | `delete` | `[project-dir] [--name <project>] --yes` | Delete the project, every version, and its route. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sproutboat",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Wrangler-shaped CLI for Sproutboat. Deploys workers to any control plane via --api-url / SPROUTBOAT_API_URL.",
5
5
  "keywords": [
6
6
  "cli",
@@ -61,7 +61,7 @@
61
61
  "lefthook": "2.1.12",
62
62
  "oxfmt": "0.66.0",
63
63
  "oxlint": "1.81.0",
64
- "typescript": "5.9.2"
64
+ "typescript": "7.0.2"
65
65
  },
66
66
  "engines": {
67
67
  "bun": ">=1.4.0"
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Version skew between a CLI and a control plane.
3
+ *
4
+ * The two update independently — the CLI from npm, a self-hosted node from its
5
+ * own `SB_REF` — so a user can easily point a months-old CLI at a fresh box or
6
+ * the reverse. Without a handshake that shows up as a 400 from a route that
7
+ * moved, which tells them nothing.
8
+ *
9
+ * The exchange is response-only: every `/api/` response carries the control
10
+ * plane's version and the oldest CLI it supports, and the CLI checks those on
11
+ * responses it was already reading. Nothing is added to requests, so an old
12
+ * control plane simply sends no headers and nothing happens.
13
+ */
14
+ import { isNewer } from "./update-check";
15
+
16
+ export const CONTROL_VERSION_HEADER = "x-sproutboat-control";
17
+ export const MIN_CLI_HEADER = "x-sproutboat-min-cli";
18
+
19
+ /**
20
+ * The warning to print for this response, or null when the pair is fine.
21
+ * Pure so it can be tested without a server; `main.ts` prints what it returns.
22
+ */
23
+ export function controlVersionWarning(response: Response, cliVersion: string): string | null {
24
+ const min = response.headers.get(MIN_CLI_HEADER);
25
+ // No header: a control plane older than this handshake. Nothing to say.
26
+ if (!min || !isNewer(min, cliVersion)) return null;
27
+ const control = response.headers.get(CONTROL_VERSION_HEADER);
28
+ return (
29
+ `this control plane${control ? ` (${control})` : ""} needs sproutboat ${min} or newer, ` +
30
+ `and you are on ${cliVersion} — upgrade with \`bun add -g sproutboat@latest\``
31
+ );
32
+ }
package/src/build.ts CHANGED
@@ -99,6 +99,7 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
99
99
  bindings,
100
100
  zigBin,
101
101
  target: input.target,
102
+ compatibilityDate: input.config.compatibility_date,
102
103
  });
103
104
 
104
105
  const sprout = await readFile(sproutPath);
@@ -111,6 +112,7 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
111
112
  porfforVersion: porfforVersion(),
112
113
  esbuildVersion: esbuildVersion(),
113
114
  buildImage: toolchainStamp(),
115
+ compatibilityDate: input.config.compatibility_date,
114
116
  sourceHash,
115
117
  binaryHash: digest(sprout),
116
118
  binarySize: (await stat(sproutPath)).size,
package/src/compile.ts CHANGED
@@ -15,7 +15,13 @@ import { ensurePorfforPatched } from "./patch-porffor";
15
15
  import { ensureUWebSockets, porfforRoot, UwsUnavailableError } from "./toolchain";
16
16
  import { EMPTY_BINDINGS, preludePath, wrapNativeFetchHandler, type Bindings } from "./wrap";
17
17
 
18
- export { EMPTY_BINDINGS, preludePath, wrapNativeFetchHandler, type Bindings } from "./wrap";
18
+ export {
19
+ BASELINE_COMPATIBILITY_DATE,
20
+ EMPTY_BINDINGS,
21
+ preludePath,
22
+ wrapNativeFetchHandler,
23
+ type Bindings,
24
+ } from "./wrap";
19
25
 
20
26
  const COMPILE_TIMEOUT_MS = Number(process.env.SPROUTBOAT_COMPILE_TIMEOUT_MS || 600_000);
21
27
 
@@ -26,6 +32,9 @@ export type CompileInput = {
26
32
  outPath: string;
27
33
  vars: Record<string, string>;
28
34
  bindings?: Bindings;
35
+ /** The project's `compatibility_date`, baked in so the runtime can gate a
36
+ * behaviour change on it. Defaults to the baseline when absent. */
37
+ compatibilityDate?: string;
29
38
  /** Cross-compiler for `linux-x86_64`. Not needed, and not used, for `host`. */
30
39
  zigBin?: string;
31
40
  /**
@@ -76,7 +85,17 @@ export async function compileSprout(input: CompileInput): Promise<void> {
76
85
  input.source === undefined ? readFile(input.sourcePath, "utf8") : Promise.resolve(input.source),
77
86
  readFile(preludePath, "utf8"),
78
87
  ]);
79
- await writeFile(generatedPath, wrapNativeFetchHandler(source, prelude, input.vars, input.bindings ?? EMPTY_BINDINGS));
88
+ await writeFile(
89
+ generatedPath,
90
+ wrapNativeFetchHandler(
91
+ source,
92
+ prelude,
93
+ input.vars,
94
+ input.bindings ?? EMPTY_BINDINGS,
95
+ undefined,
96
+ input.compatibilityDate,
97
+ ),
98
+ );
80
99
 
81
100
  const porffor = porfforRoot();
82
101
  const launcher = resolve(porffor, "runtime/index.js");
package/src/main.ts CHANGED
@@ -13,11 +13,26 @@ import { CLI_VERSION, printDeployReport } from "./report";
13
13
  import { activeApiUrl, forgetToken, savedToken, saveToken } from "./credentials";
14
14
  import { helpText, STORAGE_PRODUCTS, STORAGE_VERBS, type StorageProduct } from "./surface";
15
15
  import { notifyIfOutdated } from "./update-check";
16
+ import { controlVersionWarning } from "./api-version";
16
17
  import { amber, bold, dim, leaf, ok, rose } from "./style";
17
18
 
18
19
  const defaultApiUrl = "https://dashboard.sproutboat.com";
19
20
 
21
+ /** Warn at most once per run: every API response carries the headers, and one
22
+ * command makes several calls. */
23
+ let skewWarned = false;
24
+
20
25
  async function responseText(response: Response, failure: string): Promise<string> {
26
+ // Checked here rather than at each call site: every control-plane response
27
+ // funnels through this helper, including the failures — and a version-skew
28
+ // 400 is exactly when the user most needs to be told which side is old.
29
+ if (!skewWarned) {
30
+ const warning = controlVersionWarning(response, CLI_VERSION);
31
+ if (warning) {
32
+ skewWarned = true;
33
+ console.warn(amber(`! ${warning}`));
34
+ }
35
+ }
21
36
  if (response.ok) return response.text();
22
37
  fail(`${failure} (${response.status}): ${await response.text()}`);
23
38
  }
package/src/manifest.ts CHANGED
@@ -24,6 +24,16 @@ export type ArtifactManifest = {
24
24
  esbuildVersion: string;
25
25
  /** Build provenance, e.g. `zig-musl/0.16.0+porffor/a415d19+uws/360c276d`. */
26
26
  buildImage: string;
27
+ /**
28
+ * The project's `compatibility_date`, as `YYYY-MM-DD`. Optional: artifacts
29
+ * built before this field existed have none, and every reader must treat that
30
+ * as the baseline (see `BASELINE_COMPATIBILITY_DATE` in `wrap.ts`). This is
31
+ * how a runtime behaviour change ships without breaking deployed apps — new
32
+ * builds opt in by moving their date, old binaries keep the old semantics —
33
+ * so it is deliberately *not* part of `schemaVersion`, which stays frozen
34
+ * at 2.
35
+ */
36
+ compatibilityDate?: string;
27
37
  sourceHash: `sha256:${string}`;
28
38
  binaryHash: `sha256:${string}`;
29
39
  binarySize: number;
@@ -110,6 +120,15 @@ export function validateManifest(value: ManifestInput): ManifestValidation {
110
120
  if (binarySize === null) errors.push("binarySize must be a positive integer");
111
121
  const builtAt = isString(value.builtAt) && !Number.isNaN(Date.parse(value.builtAt)) ? value.builtAt : null;
112
122
  if (builtAt === null) errors.push("builtAt must be an ISO-8601 timestamp");
123
+ // Optional by design: an artifact from before the field existed is valid and
124
+ // must stay deployable, so "absent" is not an error — only "present and
125
+ // malformed" is.
126
+ let compatibilityDate: string | undefined;
127
+ if (value.compatibilityDate !== undefined) {
128
+ if (isString(value.compatibilityDate) && /^\d{4}-\d{2}-\d{2}$/.test(value.compatibilityDate))
129
+ compatibilityDate = value.compatibilityDate;
130
+ else errors.push("compatibilityDate must be YYYY-MM-DD");
131
+ }
113
132
  if (
114
133
  errors.length ||
115
134
  schemaVersion === null ||
@@ -142,6 +161,7 @@ export function validateManifest(value: ManifestInput): ManifestValidation {
142
161
  binaryHash,
143
162
  binarySize,
144
163
  builtAt,
164
+ compatibilityDate,
145
165
  },
146
166
  };
147
167
  }
package/src/surface.ts CHANGED
@@ -59,7 +59,7 @@ const storageCommands: readonly Command[] = STORAGE_PRODUCTS.map((product) => ({
59
59
  emoji: product.emoji,
60
60
  args: 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 are not implemented yet" : ""}.`,
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" : ""}.`,
63
63
  }));
64
64
 
65
65
  export const COMMANDS: readonly Command[] = [
@@ -28,7 +28,7 @@ function cachePath(): string {
28
28
  }
29
29
 
30
30
  /** Numeric x.y.z compare; a trailing `-tag` (prerelease) sorts before its release. */
31
- function isNewer(latest: string, current: string): boolean {
31
+ export function isNewer(latest: string, current: string): boolean {
32
32
  const parts = (v: string) =>
33
33
  v
34
34
  .split("-")[0]
package/src/wrap.ts CHANGED
@@ -17,6 +17,20 @@ export const preludePath = new URL("./native-fetch-prelude.js", import.meta.url)
17
17
  // value is only a fallback for a directly-run binary.
18
18
  const DEFAULT_PORT = 8080;
19
19
 
20
+ /**
21
+ * What an artifact with no `compatibilityDate` means. Artifacts built before
22
+ * the field existed keep the semantics of that day forever, because the binary
23
+ * is immutable and `rollback` can reactivate it at any time.
24
+ *
25
+ * How to use it: when a runtime behaviour has to change in a way that would
26
+ * break a deployed handler, don't change it unconditionally — gate it in the
27
+ * prelude on `__sbCompat >= "YYYY-MM-DD"` (ISO dates compare correctly as
28
+ * strings) and document the flip date. Old binaries carry their old date and
29
+ * keep the old behaviour; a project opts in by moving `compatibility_date` in
30
+ * its `sproutboat.jsonc` and rebuilding.
31
+ */
32
+ export const BASELINE_COMPATIBILITY_DATE = "2026-08-26";
33
+
20
34
  /**
21
35
  * Binding names a project declares. `do` maps a binding name to a Durable Object
22
36
  * class name; `crons` are schedule expressions with no name.
@@ -127,6 +141,7 @@ export function wrapNativeFetchHandler(
127
141
  vars: Record<string, string> = {},
128
142
  bindings: Bindings = EMPTY_BINDINGS,
129
143
  port: number = DEFAULT_PORT,
144
+ compatibilityDate: string = BASELINE_COMPATIBILITY_DATE,
130
145
  ): string {
131
146
  const neutralised = neutraliseExports(source);
132
147
  if (neutralised === null || !/\bfetch\s*\(/.test(source)) {
@@ -134,13 +149,16 @@ export function wrapNativeFetchHandler(
134
149
  }
135
150
 
136
151
  const env = `const env = ${JSON.stringify(vars)};\nglobalThis.env = env;\n`;
152
+ // Baked, not a binding: the date belongs to the artifact, and a handler must
153
+ // not be able to change the semantics it was compiled against at runtime.
154
+ const compat = `globalThis.__sbCompat = ${JSON.stringify(compatibilityDate)};\n`;
137
155
  const wire = hasBindings(bindings) ? `__sbInstallBindings(env, ${JSON.stringify(bindings)});\n` : "";
138
156
  const registerDO = bindings.do.length
139
157
  ? `__sbRegisterDO({ ${bindings.do.map((d) => `${d.className}: ${d.className}`).join(", ")} });\n`
140
158
  : "";
141
159
 
142
160
  return (
143
- `${prelude}\n${env}${wire}` +
161
+ `${prelude}\n${compat}${env}${wire}` +
144
162
  `${neutralised}\n` +
145
163
  `${registerDO}` +
146
164
  `export default {\n port: ${port},\n fetch(request) { return __sbEntry(__sbHandlers, request); }\n};\n`