sproutboat 0.8.0 → 0.10.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.
@@ -1,144 +1,5 @@
1
- /**
2
- * Make Porffor's generated native-fetch server read its listen port from $PORT
3
- * at runtime (falling back to the compiled `port:` value). Porffor renders the
4
- * server as C text in `compiler/render.js`; we splice two lines into
5
- * `porf_native_fetch_get_port()`.
6
- *
7
- * Done as an idempotent in-place edit rather than a `postinstall` hook: package
8
- * managers block dependency lifecycle scripts by default, so a published
9
- * `postinstall` would silently not run. This is called from the build path
10
- * instead, where it always runs.
11
- *
12
- * Tracked upstream in patches/UPSTREAM.md — once Porffor reads $PORT (or exposes
13
- * env to handlers) this whole file goes away.
14
- */
15
- import { readFile, writeFile } from "node:fs/promises";
16
- import { resolve } from "node:path";
17
- import { porfforRoot } from "./toolchain";
18
-
19
- // Each edit below is independent and idempotent, with its own marker: a file
20
- // patched by an older version of this file must still receive the newer edits.
21
- const ANCHOR = "f64 porf_native_fetch_get_port(void) {\n";
22
-
23
- /** Port from $PORT (the supervisor and `sproutboat dev` set it). */
24
- const ENV_INJECT =
25
- ' const char* __sb_port = getenv("PORT");\n' +
26
- " if (__sb_port && *__sb_port) { long __sb_v = strtol(__sb_port, NULL, 10); if (__sb_v > 0 && __sb_v < 65536) return (f64)__sb_v; }\n";
27
- const ENV_MARKER = 'getenv("PORT")';
28
-
29
- // #15 — no `--port` flag: Porffor's native-fetch entry point calls
30
- // `porf_init(0, NULL)` (see porf_native_fetch_runtime_init in render.js), so a
31
- // native-fetch binary never sees argv at all. A standalone binary takes its
32
- // port and data directory from the environment instead, which is what systemd
33
- // and docker set anyway. Worth an upstream note alongside the $PORT ask.
34
-
35
- /**
36
- * #15 — let the build add objects to the native-fetch link line.
37
- *
38
- * Porffor builds `linkArgs` as a fixed array, so an embedded backend that needs
39
- * SQLite compiled into the sprout has nowhere to put it. `CXX` is not a way in:
40
- * a musl (deploy) build overrides it outright. This splices one spread of
41
- * `SB_EXTRA_LINK` before `-lm`, inert unless the variable is set.
42
- */
43
- const LINK_ANCHOR = " uSocketsArchive,\n '-lm'\n";
44
- const LINK_INJECT =
45
- " ...(process.env.SB_EXTRA_LINK ? process.env.SB_EXTRA_LINK.split(' ').filter(Boolean) : []),\n";
46
- const LINK_MARKER = "SB_EXTRA_LINK";
47
-
48
- /**
49
- * #15 — and the same for the compile step, so the prelude's inline C can
50
- * `#include <bearssl.h>`. The link patch alone is not enough: Porffor compiles
51
- * the generated C from stdin with a fixed argument list, so there is otherwise
52
- * no way to add an include path.
53
- */
54
- const CFLAGS_ANCHOR = " '-xc', '-', '-c',\n";
55
- const CFLAGS_INJECT =
56
- " ...(process.env.SB_EXTRA_CFLAGS ? process.env.SB_EXTRA_CFLAGS.split(' ').filter(Boolean) : []),\n";
57
- const CFLAGS_MARKER = "SB_EXTRA_CFLAGS";
58
-
59
- /**
60
- * #56 — make the inbound request-body limit configurable.
61
- *
62
- * Porffor's uWebSockets shim hardcodes 1 MiB and answers anything larger with a
63
- * bare `413 request body too large` before the handler runs, so a project can
64
- * neither accept a bigger upload nor say anything useful about the refusal.
65
- *
66
- * The default stays 1 MiB: a larger body is held in memory whole, so raising it
67
- * is a decision about this deployment's memory, not something to inherit.
68
- */
69
- const BODY_ANCHOR = "static const size_t REQUEST_BODY_MAX_BYTES = 1024u * 1024u;";
70
- const BODY_INJECT = `static size_t sb_request_body_max(void) {
71
- static size_t cached = 0;
72
- if (cached == 0) {
73
- const char* raw = getenv("SB_REQUEST_BODY_MAX");
74
- long parsed = raw && *raw ? atol(raw) : 0;
75
- cached = parsed > 0 ? (size_t)parsed : 1024u * 1024u;
76
- }
77
- return cached;
78
- }
79
- #define REQUEST_BODY_MAX_BYTES sb_request_body_max()`;
80
- const BODY_MARKER = "sb_request_body_max";
81
-
82
- let done = false;
83
-
84
- /** The uWebSockets shim source, which is where the body limit lives. */
85
- async function patchBodyLimit(): Promise<void> {
86
- const file = resolve(porfforRoot(), "compiler/uwebsockets.js");
87
- const src = await readFile(file, "utf8");
88
- if (src.includes(BODY_MARKER)) return;
89
- if (!src.includes(BODY_ANCHOR)) {
90
- throw new Error(
91
- `could not patch Porffor's request body limit: anchor not found in ${file}. ` +
92
- "Porffor's uWebSockets shim changed — check patches/UPSTREAM.md.",
93
- );
94
- }
95
- await writeFile(file, src.replace(BODY_ANCHOR, BODY_INJECT));
96
- }
97
-
98
- async function patchCompilerArgs(): Promise<void> {
99
- const file = resolve(porfforRoot(), "compiler/index.js");
100
- let src = await readFile(file, "utf8");
101
- let changed = false;
102
- for (const [marker, anchor, inject, what] of [
103
- [LINK_MARKER, LINK_ANCHOR, LINK_INJECT, "extra link args"],
104
- [CFLAGS_MARKER, CFLAGS_ANCHOR, CFLAGS_INJECT, "extra compiler flags"],
105
- ] as const) {
106
- if (src.includes(marker)) continue;
107
- const at = src.indexOf(anchor);
108
- if (at === -1) {
109
- throw new Error(
110
- `could not patch Porffor for ${what}: anchor not found in ${file}. ` +
111
- "Porffor's native-fetch build changed — check patches/UPSTREAM.md.",
112
- );
113
- }
114
- // After the anchor for cflags (the args follow it), before it for the link
115
- // line (the object list ends with it).
116
- src =
117
- marker === CFLAGS_MARKER
118
- ? src.slice(0, at + anchor.length) + inject + src.slice(at + anchor.length)
119
- : src.slice(0, at) + inject + src.slice(at);
120
- changed = true;
121
- }
122
- if (changed) await writeFile(file, src);
123
- }
124
-
125
- export async function ensurePorfforPatched(): Promise<void> {
126
- if (done) return;
127
- await patchCompilerArgs();
128
- await patchBodyLimit();
129
- const file = resolve(porfforRoot(), "compiler/render.js");
130
- const src = await readFile(file, "utf8");
131
- if (src.includes(ENV_MARKER)) {
132
- done = true;
133
- return;
134
- }
135
- const anchorAt = src.indexOf(ANCHOR);
136
- if (anchorAt === -1) {
137
- throw new Error(
138
- `could not patch Porffor for $PORT: anchor not found in ${file}. ` +
139
- "Porffor's native-fetch renderer changed — check patches/UPSTREAM.md.",
140
- );
141
- }
142
- await writeFile(file, src.slice(0, anchorAt + ANCHOR.length) + ENV_INJECT + src.slice(anchorAt + ANCHOR.length));
143
- done = true;
144
- }
1
+ // Re-exported from @sproutboat/toolchain (moved verbatim). This shim keeps
2
+ // every `./patch-porffor` importer working; the patch passes now live in the
3
+ // shared package (`@sproutboat/toolchain/patch`) so the CLI and the monorepo
4
+ // apply the same set. Mirrors src/broker.ts -> @sproutboat/wire.
5
+ export { ensurePorfforPatched, patchRenderJs, patchUwebsockets } from "@sproutboat/toolchain";
@@ -0,0 +1,5 @@
1
+ // Re-exported from @sproutboat/toolchain (moved verbatim). This shim keeps
2
+ // every `./porffor-toolchain` importer working; the pin, the patches and the
3
+ // fetch/verify/cache logic now live in the shared package so the CLI and the
4
+ // monorepo cannot drift apart. Mirrors src/broker.ts -> @sproutboat/wire.
5
+ export * from "@sproutboat/toolchain";
package/src/report.ts CHANGED
@@ -1,15 +1,11 @@
1
- import { readFileSync } from "node:fs";
2
1
  import { gzipSync } from "bun";
3
2
  import { resourceRefs, type SproutboatConfig } from "./config";
4
3
  import type { ArtifactManifest } from "./manifest";
5
4
  import { bold, dim, leaf, sprout } from "./style";
6
5
 
7
- // Read from package.json so the banner never drifts from the published version.
8
- // SAFETY: our own package.json, shipped beside src/ by the `files` field; npm requires `version`.
9
- const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as {
10
- version: string;
11
- };
12
- export const CLI_VERSION = packageJson.version;
6
+ // `bun build --compile` has no package-relative filesystem at runtime. The
7
+ // release build injects this value; source development keeps the package value.
8
+ export const CLI_VERSION = process.env.SPROUTBOAT_CLI_VERSION ?? "0.9.0";
13
9
 
14
10
  function bytes(n: number): string {
15
11
  if (n < 1024) return `${n} B`;
@@ -65,6 +61,8 @@ function bindingRows(config: SproutboatConfig): string[][] {
65
61
  list(config.analytics_engine_datasets, "analytics");
66
62
  for (const [name, className] of Object.entries(config.durable_objects ?? {}))
67
63
  rows.push([`env.${name}`, "durable object", className]);
64
+ for (const rl of config.ratelimiters ?? [])
65
+ rows.push([`env.${rl.binding}`, "rate limiter", `${rl.limit} / ${rl.period}s`]);
68
66
  for (const host of config.outbound ?? []) rows.push([`fetch()`, "outbound", host]);
69
67
  for (const cron of config.triggers?.crons ?? []) rows.push([`scheduled()`, "cron", cron]);
70
68
  if (config.assets?.binding) rows.push([`env.${config.assets.binding}`, "assets", config.assets.directory ?? ""]);
package/src/source.ts CHANGED
@@ -1,44 +1,4 @@
1
- import { neutraliseExports } from "./wrap";
2
-
3
- export type SourceValidation = { ok: true } | { ok: false; errors: string[] };
4
-
5
- // Checked against the *bundled* module (#89), not the entry file: after
6
- // bundling there are no imports left to reject, and a dependency reaching for a
7
- // Node API has to fail exactly as hand-written code would. A bare specifier
8
- // that resolves to nothing never gets this far — the bundler fails first.
9
- const alwaysForbidden: Array<[RegExp, string]> = [
10
- [/^\s*import\s/m, "an import survived bundling — only static imports can be resolved at build time"],
11
- [/\bimport\s*\(/, "dynamic import() is not supported: nothing can resolve it at build time"],
12
- [/\brequire\s*\(/, "CommonJS require is not supported"],
13
- [/\b(WebSocket|XMLHttpRequest)\s*\(/, "WebSocket / XMLHttpRequest are not supported"],
14
- [/\b(process|Bun|Deno|Buffer|node:)\b/, "Node, Bun, and Deno APIs are not supported"],
15
- // Porffor alpha-4 compiles `new Proxy(...)` and then ignores the handler: a
16
- // trapped property reads back as `undefined`, with no throw. Rejecting it
17
- // here is the difference between a build error and a 502 nobody can explain.
18
- // It is why itty-router and other Proxy-based routers do not work yet.
19
- [
20
- /\bnew\s+Proxy\s*\(|\bProxy\s*\.\s*revocable\s*\(/,
21
- "Proxy is not supported by the compiler: its traps are silently ignored and the property reads back as undefined",
22
- ],
23
- ];
24
-
25
- const fetchWithoutAllowlist: [RegExp, string] = [
26
- /(?:\breturn\s+|\bawait\s+|=\s*)fetch\s*\(/,
27
- "outbound networking needs an `outbound` host allowlist in sproutboat.jsonc",
28
- ];
29
-
30
- export function validateHttpSyncSource(source: string, outboundAllowed = false): SourceValidation {
31
- const errors: string[] = [];
32
- // The default export must be an object literal with a `fetch` method. A module
33
- // may also declare Durable Object classes / helpers before it, so this is not
34
- // anchored to the start of the file.
35
- // A hand-written file exports inline; a bundled one re-exports at the end.
36
- // `neutraliseExports` is the same reader the compiler uses, so `check` cannot
37
- // accept a module the build would then reject.
38
- if (neutraliseExports(source) === null || !/\bfetch\s*\(/.test(source)) {
39
- errors.push("handler must default-export an object with fetch(request)");
40
- }
41
- for (const [pattern, message] of alwaysForbidden) if (pattern.test(source)) errors.push(message);
42
- if (!outboundAllowed && fetchWithoutAllowlist[0].test(source)) errors.push(fetchWithoutAllowlist[1]);
43
- return errors.length ? { ok: false, errors } : { ok: true };
44
- }
1
+ // Re-exported from @sproutboat/runtime (moved verbatim with wrap.ts).
2
+ // This shim keeps every `./source` importer and the
3
+ // `sproutboat/runtime/source` export path working.
4
+ export * from "@sproutboat/runtime";
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,9 +197,31 @@ 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)." },
209
+ {
210
+ name: "SPROUTBOAT_TOOLCHAIN_CACHE",
211
+ purpose: "Managed Porffor and compiler cache root (default ~/.cache/sproutboat).",
212
+ },
213
+ {
214
+ name: "SPROUTBOAT_PORFFOR_DIR",
215
+ purpose: "Contributor override for an existing Porffor source checkout. Required files are validated before use.",
216
+ },
217
+ {
218
+ name: "SPROUTBOAT_BUILD_UWS_FROM_SOURCE",
219
+ purpose: "Set to 1 to explicitly allow the contributor-only Git and Make uWebSockets fallback.",
220
+ },
221
+ {
222
+ name: "SPROUTBOAT_CLI_VERSION",
223
+ purpose: "CLI version embedded by the release executable build. Not normally set by users.",
224
+ },
195
225
  {
196
226
  name: "SPROUTBOAT_VARS_JSON",
197
227
  purpose:
package/src/toolchain.ts CHANGED
@@ -7,14 +7,17 @@
7
7
  */
8
8
  import { createHash } from "node:crypto";
9
9
  import { existsSync, readFileSync } from "node:fs";
10
- import { chmod, mkdir, readFile, rm } from "node:fs/promises";
10
+ import { chmod, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
11
11
  import { homedir } from "node:os";
12
12
  import { dirname, resolve } from "node:path";
13
+ // @ts-expect-error Bun's file loader returns the embedded asset path in a compiled executable.
14
+ import embeddedUwsArchive from "../vendor/uwebsockets-360c276d-musl.tar.xz" with { type: "file" };
15
+ import { cachedPorfforRoot, PORFFOR_CHANNEL, PORFFOR_COMMIT } from "./porffor-toolchain";
13
16
 
14
17
  export const ZIG_VERSION = "0.16.0";
15
18
 
16
19
  /** The `<arch>-<os>` platforms ziglang.org publishes a tarball for that we pin. */
17
- type ZigPlatform = "x86_64-linux" | "aarch64-linux" | "x86_64-macos" | "aarch64-macos";
20
+ export type ZigPlatform = "x86_64-linux" | "aarch64-linux" | "x86_64-macos" | "aarch64-macos";
18
21
 
19
22
  // sha256 of the official ziglang.org tarballs for ZIG_VERSION, keyed by
20
23
  // `<arch>-<os>` (the download naming). Bump alongside ZIG_VERSION.
@@ -25,17 +28,12 @@ const ZIG_SHA256 = {
25
28
  "aarch64-macos": "b23d70deaa879b5c2d486ed3316f7eaa53e84acf6fc9cc747de152450d401489",
26
29
  } satisfies Record<ZigPlatform, string>;
27
30
 
28
- // Pinned Porffor identity — must match the `porffor` entry in package.json
29
- // (`github:CanadaHonk/porffor#alpha-4`, commit a415d19). PORFFOR_VERSION overrides.
31
+ // Pinned Porffor identity comes from porffor-toolchain.ts. PORFFOR_VERSION overrides.
30
32
  // When bumping this pin (#55): run the monorepo's `bun run diff` against the
31
33
  // frozen reference handlers and update its COMPAT.md for any new mismatch before
32
34
  // releasing — the alpha compiler's output can shift between pins. Checked by
33
35
  // hand at bump time, not in CI.
34
- const PORFFOR_CHANNEL = "alpha-4";
35
- const PORFFOR_COMMIT = "a415d19";
36
-
37
- // uWebSockets commit Porffor alpha-4 fetches for the native-fetch server. Read
38
- // from node_modules/porffor at build time; this is the fallback for the stamp.
36
+ // uWebSockets commit Porffor alpha-4 fetches for the native-fetch server.
39
37
  const UWS_COMMIT = "360c276d";
40
38
  const UWS_COMMIT_FULL = "360c276d609d59af56ae6932adb95154ace9f15f";
41
39
 
@@ -56,51 +54,157 @@ function platformKey(): ZigPlatform {
56
54
  return `${arch}-${os}`;
57
55
  }
58
56
 
57
+ export class ZigToolchainError extends Error {
58
+ constructor(
59
+ readonly kind: "download" | "integrity" | "archive" | "cache" | "unsupported",
60
+ message: string,
61
+ ) {
62
+ super(message);
63
+ }
64
+ }
65
+
66
+ export type EnsureZigOptions = {
67
+ cacheRoot?: string;
68
+ platform?: ZigPlatform;
69
+ url?: string;
70
+ expectedSha256?: string;
71
+ fetcher?: (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
72
+ timeoutMs?: number;
73
+ };
74
+
59
75
  async function sha256File(path: string): Promise<string> {
60
76
  const hash = createHash("sha256");
61
77
  hash.update(await readFile(path));
62
78
  return hash.digest("hex");
63
79
  }
64
80
 
65
- /** Absolute path to a usable `zig` binary, downloading it on first use. */
66
- export async function ensureZig(): Promise<string> {
81
+ async function zigComplete(dir: string, key: ZigPlatform, expectedArchive: string): Promise<boolean> {
82
+ try {
83
+ // SAFETY: this manifest is private data written below; its values are only
84
+ // accepted when the immutable identity and binary digest both match.
85
+ const manifest = JSON.parse(await readFile(resolve(dir, ".sproutboat-complete"), "utf8")) as {
86
+ version?: string;
87
+ platform?: string;
88
+ archiveSha256?: string;
89
+ binarySha256?: string;
90
+ };
91
+ const bin = resolve(dir, "zig");
92
+ return (
93
+ manifest.version === ZIG_VERSION &&
94
+ manifest.platform === key &&
95
+ manifest.archiveSha256 === expectedArchive &&
96
+ manifest.binarySha256 !== undefined &&
97
+ (await sha256File(bin)) === manifest.binarySha256
98
+ );
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ async function downloadZig(
105
+ url: string,
106
+ path: string,
107
+ fetcher: NonNullable<EnsureZigOptions["fetcher"]>,
108
+ timeoutMs: number,
109
+ ): 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;
119
+ }
120
+ }
121
+ throw new ZigToolchainError("download", `could not download pinned Zig from ${url}: ${String(last)}`);
122
+ }
123
+
124
+ async function waitForZigPublisher(
125
+ dir: string,
126
+ lock: string,
127
+ key: ZigPlatform,
128
+ expected: string,
129
+ ): Promise<string | null> {
130
+ const deadline = Date.now() + 30_000;
131
+ while (Date.now() < deadline) {
132
+ if (await zigComplete(dir, key, expected)) return resolve(dir, "zig");
133
+ if (!existsSync(lock)) return null;
134
+ if (Date.now() - (await stat(lock)).mtimeMs > 5 * 60_000) {
135
+ await rm(lock, { recursive: true, force: true });
136
+ return null;
137
+ }
138
+ await Bun.sleep(25);
139
+ }
140
+ throw new ZigToolchainError("cache", `timed out waiting for Zig cache lock ${lock}`);
141
+ }
142
+
143
+ /** Absolute path to a verified Zig binary, downloading it on first use. */
144
+ export async function ensureZig(options: EnsureZigOptions = {}): Promise<string> {
67
145
  const override = process.env.SPROUTBOAT_ZIG;
68
146
  if (override) {
69
- if (!existsSync(override)) throw new Error(`SPROUTBOAT_ZIG=${override} does not exist`);
147
+ if (!existsSync(override)) throw new ZigToolchainError("unsupported", `SPROUTBOAT_ZIG=${override} does not exist`);
70
148
  return override;
71
149
  }
72
- const key = platformKey();
73
- const home = homedir();
74
- const dir = resolve(home, ".cache/sproutboat", `zig-${ZIG_VERSION}`);
75
- const bin = resolve(dir, "zig");
76
- if (existsSync(bin)) return bin;
77
-
78
- const url = `https://ziglang.org/download/${ZIG_VERSION}/zig-${key}-${ZIG_VERSION}.tar.xz`;
79
- const expected = ZIG_SHA256[key];
150
+ const key = options.platform ?? platformKey();
151
+ const root = resolve(
152
+ options.cacheRoot ?? process.env.SPROUTBOAT_TOOLCHAIN_CACHE ?? resolve(homedir(), ".cache/sproutboat"),
153
+ );
154
+ const dir = resolve(root, `zig-${ZIG_VERSION}-${key}`);
155
+ const expected = options.expectedSha256 ?? ZIG_SHA256[key];
156
+ if (await zigComplete(dir, key, expected)) return resolve(dir, "zig");
157
+ await mkdir(root, { recursive: true });
158
+ const lock = `${dir}.lock`;
159
+ try {
160
+ await mkdir(lock);
161
+ } catch {
162
+ const published = await waitForZigPublisher(dir, lock, key, expected);
163
+ if (published) return published;
164
+ return ensureZig(options);
165
+ }
166
+ 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`;
80
168
  console.log(`Fetching Zig ${ZIG_VERSION} (${key}, one-time)...`);
81
- await mkdir(dir, { recursive: true });
82
- const archive = resolve(dir, "zig.tar.xz");
83
- const response = await fetch(url);
84
- if (!response.ok) throw new Error(`could not download Zig: ${url} (${response.status})`);
85
- await Bun.write(archive, response);
86
-
87
- const actual = await sha256File(archive);
88
- if (expected && actual !== expected) {
169
+ try {
170
+ if (await zigComplete(dir, key, expected)) return resolve(dir, "zig");
89
171
  await rm(dir, { recursive: true, force: true });
90
- throw new Error(`Zig download sha256 mismatch\n expected ${expected}\n got ${actual}`);
172
+ await mkdir(stage);
173
+ const archive = resolve(stage, "zig.tar.xz");
174
+ await downloadZig(url, archive, options.fetcher ?? fetch, options.timeoutMs ?? 30_000);
175
+ const actual = await sha256File(archive);
176
+ if (actual !== expected)
177
+ throw new ZigToolchainError(
178
+ "integrity",
179
+ `Zig archive sha256 mismatch\n expected ${expected}\n got ${actual}`,
180
+ );
181
+ const untar = Bun.spawn(["tar", "-xJf", archive, "-C", stage, "--strip-components=1"], {
182
+ stdout: "pipe",
183
+ stderr: "pipe",
184
+ });
185
+ const [code, err] = await Promise.all([untar.exited, new Response(untar.stderr).text()]);
186
+ if (code !== 0)
187
+ throw new ZigToolchainError("archive", `could not extract Zig (needs \`tar\` with xz support): ${err.trim()}`);
188
+ await rm(archive, { force: true });
189
+ const bin = resolve(stage, "zig");
190
+ if (!existsSync(bin)) throw new ZigToolchainError("archive", "Zig archive did not contain a `zig` binary");
191
+ await chmod(bin, 0o755);
192
+ await writeFile(
193
+ resolve(stage, ".sproutboat-complete"),
194
+ JSON.stringify({
195
+ version: ZIG_VERSION,
196
+ platform: key,
197
+ archiveSha256: actual,
198
+ binarySha256: await sha256File(bin),
199
+ }),
200
+ { mode: 0o444 },
201
+ );
202
+ await rename(stage, dir);
203
+ return resolve(dir, "zig");
204
+ } finally {
205
+ await rm(stage, { recursive: true, force: true });
206
+ await rm(lock, { recursive: true, force: true });
91
207
  }
92
-
93
- // `tar -xJ` (xz) works on macOS bsdtar and GNU tar with xz on PATH.
94
- const untar = Bun.spawn(["tar", "-xJf", archive, "-C", dir, "--strip-components=1"], {
95
- stdout: "pipe",
96
- stderr: "pipe",
97
- });
98
- const [code, err] = await Promise.all([untar.exited, new Response(untar.stderr).text()]);
99
- if (code !== 0) throw new Error(`could not extract Zig (needs \`tar\` with xz support): ${err.trim()}`);
100
- await rm(archive, { force: true });
101
- if (!existsSync(bin)) throw new Error("Zig archive did not contain a `zig` binary");
102
- await chmod(bin, 0o755);
103
- return bin;
104
208
  }
105
209
 
106
210
  function uwsCommitFull(): string {
@@ -117,7 +221,9 @@ export class UwsUnavailableError extends Error {}
117
221
 
118
222
  /** Path to the vendored prebuilt archive for the given short commit. */
119
223
  export function uwsVendorArchive(short: string): string {
120
- return resolve(import.meta.dir, "..", "vendor", `uwebsockets-${short}-musl.tar.xz`);
224
+ return short === UWS_COMMIT_FULL.slice(0, 8)
225
+ ? embeddedUwsArchive
226
+ : resolve(import.meta.dir, "..", "vendor", `uwebsockets-${short}-musl.tar.xz`);
121
227
  }
122
228
 
123
229
  /**
@@ -156,26 +262,104 @@ export async function ensureUWebSockets(): Promise<void> {
156
262
  }
157
263
  }
158
264
 
265
+ await extractUws(archive, dir);
266
+ }
267
+
268
+ /** Unpack the vendored source tree into `dir`. */
269
+ async function extractUws(archive: string, dir: string): Promise<void> {
159
270
  await mkdir(dir, { recursive: true });
160
- const untar = Bun.spawn(["tar", "-xJf", archive, "-C", dir, "--strip-components=1"], {
271
+ // External programs cannot read Bun's virtual /$bunfs paths. Materialize the
272
+ // embedded release asset inside the destination before handing it to tar.
273
+ const readableArchive = archive.includes("/$bunfs/") ? resolve(dir, ".sproutboat-uwebsockets.tar.xz") : archive;
274
+ if (readableArchive !== archive) await Bun.write(readableArchive, Bun.file(archive));
275
+ const untar = Bun.spawn(["tar", "-xJf", readableArchive, "-C", dir, "--strip-components=1"], {
161
276
  stdout: "pipe",
162
277
  stderr: "pipe",
163
278
  });
164
279
  const [code, err] = await Promise.all([untar.exited, new Response(untar.stderr).text()]);
280
+ if (readableArchive !== archive) await rm(readableArchive, { force: true });
165
281
  if (code !== 0) {
166
282
  await rm(dir, { recursive: true, force: true });
167
283
  throw new UwsUnavailableError(`could not extract vendored uWebSockets: ${err.trim()}`);
168
284
  }
169
285
  }
170
286
 
287
+ /**
288
+ * The same seeding for a **host** build (`dev`, `build --target host`,
289
+ * `--standalone` on this machine).
290
+ *
291
+ * The vendored archive is a full checkout, so the sources are the same for
292
+ * every target; only `uSockets.a` is target-specific. Porffor would build that
293
+ * with `make -C uSockets`, which is where the `git` and `make` requirements on
294
+ * a first build came from. Compiling the thirteen C files directly removes
295
+ * both, and adds no new dependency: a host build already needs a C compiler,
296
+ * because that is what Porffor compiles its own generated C with.
297
+ *
298
+ * Flags mirror the uSockets makefile's default target with the WITH_* switches
299
+ * Porffor passes (all off): `-std=c11 -Isrc -DLIBUS_NO_SSL -flto -O3`, then
300
+ * `ar rvs`. Nothing here is a judgement call; it is that recipe.
301
+ */
302
+ export async function ensureUWebSocketsHost(): Promise<void> {
303
+ const commit = uwsCommitFull();
304
+ const dir = resolve(homedir(), ".cache/porffor/deps", `uWebSockets-${commit}`);
305
+ const uSockets = resolve(dir, "uSockets");
306
+ const archivePath = resolve(uSockets, "uSockets.a");
307
+ if (existsSync(resolve(dir, "src/App.h")) && existsSync(archivePath)) return;
308
+
309
+ const vendored = process.env.SPROUTBOAT_UWS_TARBALL || uwsVendorArchive(commit.slice(0, 8));
310
+ if (!existsSync(vendored)) {
311
+ throw new UwsUnavailableError(`no vendored uWebSockets archive at ${vendored}`);
312
+ }
313
+ if (!process.env.SPROUTBOAT_UWS_TARBALL) {
314
+ const actual = await sha256File(vendored);
315
+ if (actual !== UWS_TARBALL_SHA256) {
316
+ throw new UwsUnavailableError(
317
+ `vendored uWebSockets sha256 mismatch\n expected ${UWS_TARBALL_SHA256}\n got ${actual}`,
318
+ );
319
+ }
320
+ }
321
+ if (!existsSync(resolve(dir, "src/App.h"))) await extractUws(vendored, dir);
322
+
323
+ // The archive carries the musl-built uSockets.a. Linking that into a host
324
+ // binary fails in a way nobody would connect to this, so it goes first.
325
+ await rm(archivePath, { force: true });
326
+
327
+ const sources = ["src/*.c", "src/eventing/*.c", "src/crypto/*.c", "src/io_uring/*.c"];
328
+ const cc = process.env.CC || "cc";
329
+ const ar = process.env.AR || "ar";
330
+ const compile = Bun.spawn(["sh", "-c", `${cc} -std=c11 -Isrc -DLIBUS_NO_SSL -flto -O3 -c ${sources.join(" ")}`], {
331
+ cwd: uSockets,
332
+ stdout: "pipe",
333
+ stderr: "pipe",
334
+ });
335
+ const [ccCode, ccErr] = await Promise.all([compile.exited, new Response(compile.stderr).text()]);
336
+ if (ccCode !== 0) {
337
+ throw new UwsUnavailableError(
338
+ `could not compile uSockets with ${cc}: ${ccErr.trim().split("\n").slice(-3).join(" ")}`,
339
+ );
340
+ }
341
+
342
+ const archiveStep = Bun.spawn(["sh", "-c", `${ar} rvs uSockets.a *.o`], {
343
+ cwd: uSockets,
344
+ stdout: "pipe",
345
+ stderr: "pipe",
346
+ });
347
+ const [arCode, arErr] = await Promise.all([archiveStep.exited, new Response(archiveStep.stderr).text()]);
348
+ if (arCode !== 0 || !existsSync(archivePath)) {
349
+ throw new UwsUnavailableError(`could not archive uSockets with ${ar}: ${arErr.trim()}`);
350
+ }
351
+ }
352
+
171
353
  /** Directory holding node_modules/porffor (walks up from this file). */
172
354
  export function porfforRoot(start = import.meta.dir): string {
355
+ const managed = cachedPorfforRoot();
356
+ if (existsSync(resolve(managed, "runtime/index.js"))) return managed;
173
357
  let dir = start;
174
358
  for (;;) {
175
359
  const candidate = resolve(dir, "node_modules/porffor");
176
360
  if (existsSync(resolve(candidate, "runtime/index.js"))) return candidate;
177
361
  const parent = dirname(dir);
178
- if (parent === dir) throw new Error("node_modules/porffor not found — run `bun install`");
362
+ if (parent === dir) return managed;
179
363
  dir = parent;
180
364
  }
181
365
  }