sproutboat 0.10.1 → 0.10.3

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 CHANGED
@@ -10,6 +10,42 @@ maintained going forward by the `release` skill.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.10.3] - 2026-09-11
14
+ ### Added
15
+ - Rate-limiter result carries `resetAt`: `env.<NAME>.limit({ key })` now returns
16
+ `{ success, resetAt }`, where `resetAt` is epoch ms for when the fixed window
17
+ rolls, so a `429` can send an accurate `Retry-After`
18
+ (baronunread/sproutboat#69). Additive; callers reading only `success` are
19
+ unaffected.
20
+
21
+ ### Changed
22
+ - `@sproutboat/runtime` and `@sproutboat/wire` bumped to `^0.5.0`; they carry
23
+ the `resetAt` value the rate-limiter type now declares.
24
+
25
+ ### Docs
26
+ - `docs/standalone.md`: dropped the stale "outbound `fetch()` speaks `http://`
27
+ only" bullet. Standalone binaries link BearSSL and verify HTTPS against the
28
+ Mozilla root set (see "TLS, in both directions"). Added a
29
+ dev/standalone/deployed parity note: every binding op, rate limiters and
30
+ `request.cf.clientIp` with `SB_TRUSTED_PROXIES` included, behaves the same
31
+ under `sproutboat dev` as in a standalone binary and a deployed sprout.
32
+ - `capability-http-sync-v0.md`: documented async handlers. `fetch` may return a
33
+ promise it creates (not a `.then()`-chained one, which hangs); async responses
34
+ omit `x-sb-cpu-ms`; `crypto.subtle` digest/sign/verify are async in signature
35
+ only over synchronous inline C, so per-request hashing costs no I/O.
36
+
37
+ ## [0.10.2] - 2026-09-11
38
+ ### Fixed
39
+ - The one-time Zig download no longer depends on ziglang.org being reachable.
40
+ It now pulls from a random Zig community mirror, with ziglang.org kept only
41
+ as a last resort, and tries the mirrors in turn on failure. ziglang.org
42
+ rate-limits the multi-megabyte tarballs and had been timing out mid-build,
43
+ with no fallback (baronunread/sproutboat#166). The archive sha256 is still
44
+ enforced whatever the source. `SPROUTBOAT_ZIG_URL` overrides the source;
45
+ `SPROUTBOAT_ZIG` still points at a prebuilt binary.
46
+ - A leftover `~/.cache/sproutboat/zig-<version>/` directory from an earlier
47
+ cache layout is removed on the next build instead of sitting unused.
48
+
13
49
  ## [0.10.1] - 2026-09-11
14
50
  ### Fixed
15
51
  - `npm install sproutboat` / `bunx sproutboat` work again. 0.10.0's launcher
@@ -358,7 +394,9 @@ its own package.
358
394
  - Renamed the package to `sproutboat` (was `@sproutboat/cli`); dropped the
359
395
  `sprout` bin alias in favour of a user-defined shell alias.
360
396
 
361
- [Unreleased]: https://github.com/baronunread/sproutboat-cli/compare/v0.10.1...HEAD
397
+ [Unreleased]: https://github.com/baronunread/sproutboat-cli/compare/v0.10.3...HEAD
398
+ [0.10.3]: https://github.com/baronunread/sproutboat-cli/compare/v0.10.2...v0.10.3
399
+ [0.10.2]: https://github.com/baronunread/sproutboat-cli/compare/v0.10.1...v0.10.2
362
400
  [0.10.1]: https://github.com/baronunread/sproutboat-cli/compare/v0.10.0...v0.10.1
363
401
  [0.10.0]: https://github.com/baronunread/sproutboat-cli/compare/v0.9.0...v0.10.0
364
402
  [0.9.0]: https://github.com/baronunread/sproutboat-cli/compare/v0.8.0...v0.9.0
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.10.1 · runs on Bun (use `bunx`, not `npx`)
6
+ **Package:** `sproutboat` 0.10.3 · runs on Bun (use `bunx`, not `npx`)
7
7
 
8
8
  ## Commands
9
9
 
@@ -40,6 +40,7 @@ usage: sproutboat <init [name] | check [project-dir] | types [project-dir] | dev
40
40
  | `SPROUTBOAT_API_URL` | Control-plane URL. Overrides the saved active endpoint. |
41
41
  | `SPROUTBOAT_TOKEN` | API token. Overrides the saved credential for the endpoint. |
42
42
  | `SPROUTBOAT_ZIG` | Path to a Zig binary to use instead of downloading the pinned one. |
43
+ | `SPROUTBOAT_ZIG_URL` | URL to download the pinned Zig tarball from, instead of a Zig community mirror. The sha256 is still enforced. |
43
44
  | `SPROUTBOAT_UWS_TARBALL` | 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. |
44
45
  | `CC` | 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. |
45
46
  | `AR` | Archiver used to assemble uSockets.a for a host build (default `ar`). |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sproutboat",
3
- "version": "0.10.1",
3
+ "version": "0.10.3",
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",
@@ -63,9 +63,9 @@
63
63
  "@sproutboat/artifact": "^0.2.0",
64
64
  "@sproutboat/assets": "^0.2.0",
65
65
  "@sproutboat/config": "^0.3.0",
66
- "@sproutboat/runtime": "^0.4.0",
66
+ "@sproutboat/runtime": "^0.5.0",
67
67
  "@sproutboat/toolchain": "^0.3.0",
68
- "@sproutboat/wire": "^0.4.0",
68
+ "@sproutboat/wire": "^0.5.0",
69
69
  "esbuild": "^0.28.2"
70
70
  },
71
71
  "devDependencies": {
package/src/surface.ts CHANGED
@@ -194,6 +194,11 @@ export const ENV_VARS: readonly EnvVar[] = [
194
194
  { name: "SPROUTBOAT_API_URL", purpose: "Control-plane URL. Overrides the saved active endpoint." },
195
195
  { name: "SPROUTBOAT_TOKEN", purpose: "API token. Overrides the saved credential for the endpoint." },
196
196
  { name: "SPROUTBOAT_ZIG", purpose: "Path to a Zig binary to use instead of downloading the pinned one." },
197
+ {
198
+ name: "SPROUTBOAT_ZIG_URL",
199
+ purpose:
200
+ "URL to download the pinned Zig tarball from, instead of a Zig community mirror. The sha256 is still enforced.",
201
+ },
197
202
  {
198
203
  name: "SPROUTBOAT_UWS_TARBALL",
199
204
  purpose:
package/src/toolchain.ts CHANGED
@@ -2,8 +2,11 @@
2
2
  * The build toolchain: a pinned Zig (the linux-x86_64 cross-compiler Porffor
3
3
  * shells out to for `--musl`) plus version stamps for the artifact manifest.
4
4
  *
5
- * Zig is fetched once to ~/.cache/sproutboat/zig-<version>/ and reused. No
6
- * Docker, no root. Override with SPROUTBOAT_ZIG=/path/to/zig.
5
+ * Zig is fetched once to ~/.cache/sproutboat/zig-<version>-<platform>/ and
6
+ * reused. No Docker, no root. It comes from a random Zig community mirror with
7
+ * ziglang.org as the last resort, because ziglang.org rate-limits the large
8
+ * tarballs and has been timing out. Override the binary with
9
+ * SPROUTBOAT_ZIG=/path/to/zig, or the download source with SPROUTBOAT_ZIG_URL.
7
10
  */
8
11
  import { createHash } from "node:crypto";
9
12
  import { existsSync, readFileSync } from "node:fs";
@@ -101,24 +104,69 @@ async function zigComplete(dir: string, key: ZigPlatform, expectedArchive: strin
101
104
  }
102
105
  }
103
106
 
107
+ // Zig asks tooling not to hammer ziglang.org for the multi-megabyte tarballs:
108
+ // pick a random community mirror and keep the official host only as a last
109
+ // resort. https://ziglang.org/download/community-mirrors.txt
110
+ const ZIG_MIRRORS_URL = "https://ziglang.org/download/community-mirrors.txt";
111
+
112
+ function shuffle<T>(items: T[]): T[] {
113
+ for (let i = items.length - 1; i > 0; i--) {
114
+ const j = Math.floor(Math.random() * (i + 1));
115
+ [items[i], items[j]] = [items[j], items[i]];
116
+ }
117
+ return items;
118
+ }
119
+
120
+ /**
121
+ * Ordered list of URLs to try for the pinned Zig tarball: the community mirrors
122
+ * (shuffled) followed by ziglang.org. Falls back to ziglang.org alone if the
123
+ * mirror list itself is unreachable.
124
+ */
125
+ async function zigDownloadUrls(
126
+ key: ZigPlatform,
127
+ fetcher: NonNullable<EnsureZigOptions["fetcher"]>,
128
+ timeoutMs: number,
129
+ ): Promise<string[]> {
130
+ const file = `zig-${key}-${ZIG_VERSION}.tar.xz`;
131
+ const official = `https://ziglang.org/download/${ZIG_VERSION}/${file}`;
132
+ try {
133
+ const response = await fetcher(ZIG_MIRRORS_URL, { signal: AbortSignal.timeout(Math.min(timeoutMs, 10_000)) });
134
+ if (response.ok) {
135
+ const mirrors = (await response.text())
136
+ .split(/\s+/)
137
+ .filter((line) => line.startsWith("https://"))
138
+ .map((base) => `${base.replace(/\/+$/, "")}/${file}`);
139
+ if (mirrors.length > 0) return [...shuffle(mirrors), official];
140
+ }
141
+ } catch {
142
+ // Mirror list unreachable: the official host is the only option left.
143
+ }
144
+ return [official];
145
+ }
146
+
104
147
  async function downloadZig(
105
- url: string,
148
+ urls: string[],
106
149
  path: string,
107
150
  fetcher: NonNullable<EnsureZigOptions["fetcher"]>,
108
151
  timeoutMs: number,
109
152
  ): Promise<void> {
110
- let last: unknown;
111
- for (let attempt = 0; attempt < 2; attempt++) {
112
- try {
113
- const response = await fetcher(url, { signal: AbortSignal.timeout(timeoutMs) });
114
- if (!response.ok) throw new Error(`HTTP ${response.status}`);
115
- await writeFile(path, new Uint8Array(await response.arrayBuffer()));
116
- return;
117
- } catch (error) {
118
- last = error;
153
+ // One shot per mirror; two at the single fallback URL so a lone flaky host
154
+ // still gets a retry.
155
+ const attemptsPer = urls.length > 1 ? 1 : 2;
156
+ const failures: string[] = [];
157
+ for (const url of urls) {
158
+ for (let attempt = 0; attempt < attemptsPer; attempt++) {
159
+ try {
160
+ const response = await fetcher(url, { signal: AbortSignal.timeout(timeoutMs) });
161
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
162
+ await writeFile(path, new Uint8Array(await response.arrayBuffer()));
163
+ return;
164
+ } catch (error) {
165
+ failures.push(`${url}: ${String(error)}`);
166
+ }
119
167
  }
120
168
  }
121
- throw new ZigToolchainError("download", `could not download pinned Zig from ${url}: ${String(last)}`);
169
+ throw new ZigToolchainError("download", `could not download pinned Zig\n ${failures.join("\n ")}`);
122
170
  }
123
171
 
124
172
  async function waitForZigPublisher(
@@ -151,6 +199,10 @@ export async function ensureZig(options: EnsureZigOptions = {}): Promise<string>
151
199
  const root = resolve(
152
200
  options.cacheRoot ?? process.env.SPROUTBOAT_TOOLCHAIN_CACHE ?? resolve(homedir(), ".cache/sproutboat"),
153
201
  );
202
+ // Older builds cached Zig at `zig-<version>/` with no platform suffix; nothing
203
+ // reads that layout now, so drop it rather than leave it as dead weight.
204
+ const legacy = resolve(root, `zig-${ZIG_VERSION}`);
205
+ if (existsSync(legacy)) await rm(legacy, { recursive: true, force: true }).catch(() => {});
154
206
  const dir = resolve(root, `zig-${ZIG_VERSION}-${key}`);
155
207
  const expected = options.expectedSha256 ?? ZIG_SHA256[key];
156
208
  if (await zigComplete(dir, key, expected)) return resolve(dir, "zig");
@@ -164,14 +216,16 @@ export async function ensureZig(options: EnsureZigOptions = {}): Promise<string>
164
216
  return ensureZig(options);
165
217
  }
166
218
  const stage = resolve(root, `.zig-${ZIG_VERSION}-${key}-${process.pid}-${crypto.randomUUID()}`);
167
- const url = options.url ?? `https://ziglang.org/download/${ZIG_VERSION}/zig-${key}-${ZIG_VERSION}.tar.xz`;
219
+ const fetcher = options.fetcher ?? fetch;
220
+ const sourceUrl = options.url ?? process.env.SPROUTBOAT_ZIG_URL;
221
+ const urls = sourceUrl ? [sourceUrl] : await zigDownloadUrls(key, fetcher, options.timeoutMs ?? 30_000);
168
222
  console.log(`Fetching Zig ${ZIG_VERSION} (${key}, one-time)...`);
169
223
  try {
170
224
  if (await zigComplete(dir, key, expected)) return resolve(dir, "zig");
171
225
  await rm(dir, { recursive: true, force: true });
172
226
  await mkdir(stage);
173
227
  const archive = resolve(stage, "zig.tar.xz");
174
- await downloadZig(url, archive, options.fetcher ?? fetch, options.timeoutMs ?? 30_000);
228
+ await downloadZig(urls, archive, fetcher, options.timeoutMs ?? 60_000);
175
229
  const actual = await sha256File(archive);
176
230
  if (actual !== expected)
177
231
  throw new ZigToolchainError(
@@ -182,9 +182,13 @@ declare global {
182
182
  * A rate-limiter binding (#69). `limit` and `period` are fixed in
183
183
  * sproutboat.jsonc; `limit({ key })` counts one call against a fixed window
184
184
  * and reports whether the key is still under the cap.
185
+ *
186
+ * `resetAt` is epoch milliseconds for when the window rolls and the count
187
+ * clears — the same for a hit or a miss. Turn a rejection into a header with
188
+ * `Retry-After: Math.ceil((resetAt - Date.now()) / 1000)`.
185
189
  */
186
190
  interface RateLimit {
187
- limit(options: { key: string }): { success: boolean };
191
+ limit(options: { key: string }): { success: boolean; resetAt: number };
188
192
  }
189
193
 
190
194
  // ------------------------------------ Crypto