sproutboat 0.10.1 → 0.10.2

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,18 @@ maintained going forward by the `release` skill.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.10.2] - 2026-09-11
14
+ ### Fixed
15
+ - The one-time Zig download no longer depends on ziglang.org being reachable.
16
+ It now pulls from a random Zig community mirror, with ziglang.org kept only
17
+ as a last resort, and tries the mirrors in turn on failure. ziglang.org
18
+ rate-limits the multi-megabyte tarballs and had been timing out mid-build,
19
+ with no fallback (baronunread/sproutboat#166). The archive sha256 is still
20
+ enforced whatever the source. `SPROUTBOAT_ZIG_URL` overrides the source;
21
+ `SPROUTBOAT_ZIG` still points at a prebuilt binary.
22
+ - A leftover `~/.cache/sproutboat/zig-<version>/` directory from an earlier
23
+ cache layout is removed on the next build instead of sitting unused.
24
+
13
25
  ## [0.10.1] - 2026-09-11
14
26
  ### Fixed
15
27
  - `npm install sproutboat` / `bunx sproutboat` work again. 0.10.0's launcher
@@ -358,7 +370,8 @@ its own package.
358
370
  - Renamed the package to `sproutboat` (was `@sproutboat/cli`); dropped the
359
371
  `sprout` bin alias in favour of a user-defined shell alias.
360
372
 
361
- [Unreleased]: https://github.com/baronunread/sproutboat-cli/compare/v0.10.1...HEAD
373
+ [Unreleased]: https://github.com/baronunread/sproutboat-cli/compare/v0.10.2...HEAD
374
+ [0.10.2]: https://github.com/baronunread/sproutboat-cli/compare/v0.10.1...v0.10.2
362
375
  [0.10.1]: https://github.com/baronunread/sproutboat-cli/compare/v0.10.0...v0.10.1
363
376
  [0.10.0]: https://github.com/baronunread/sproutboat-cli/compare/v0.9.0...v0.10.0
364
377
  [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.2 · 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.2",
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",
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(