sproutboat 0.48.1 → 0.50.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/CHANGELOG.md CHANGED
@@ -10,6 +10,42 @@ maintained going forward by the `release` skill.
10
10
 
11
11
  ## Unreleased
12
12
 
13
+ ## [0.50.0] - 2026-11-02
14
+
15
+ Built on `@sproutboat/runtime` 0.41.0, `@sproutboat/toolchain` 0.22.0 and `@sproutboat/wire` 0.21.4. Each change below that an existing app could observe starts at a new compatibility date; earlier dates answer as before.
16
+
17
+ ### Added
18
+
19
+ - **`fetch()` on a standalone binary runs off the event loop from `2026-11-16`**: 20 concurrent fetches to a 100 ms endpoint take about
20
+ 105 ms instead of 2,040 ms, and other requests keep being served meanwhile. Requests now interleave across `await fetch()`, as in workerd,
21
+ which is why it starts at a new date; earlier dates answer as before.
22
+
23
+ ### Changed
24
+
25
+ - **Secrets from the environment decode as UTF-8 from `2026-11-07`** on a standalone binary. Before, the handler got the environment's bytes one char per byte, so `é` arrived as `é`.
26
+
27
+ ### Performance
28
+
29
+ - **ES256 (ECDSA P-256) in `crypto.subtle` signs 2.2x and verifies 3.1x faster** (macOS arm64, standalone and broker: 2,563 to 5,621 signs and 1,318 to 4,050 verifies per second). BearSSL is pinned to master commit `7bea48e` instead of 0.6 (source: the bearssl.org gitweb snapshot, sha256 `ed397832…`) and the runtime selects its 64-bit P-256 code (`ec_p256_m64`) where the target has a 128-bit multiply, which includes both Linux targets. Results are unchanged, so there is no new compatibility date; every other primitive is as before.
30
+
31
+ ## [0.49.0] - 2026-10-31
32
+
33
+ Built on `@sproutboat/runtime` 0.39.0, `@sproutboat/toolchain` 0.22.0 and `@sproutboat/wire` 0.21.2. Nothing an app can observe changes, so there is no new compatibility date.
34
+
35
+ ### Added
36
+
37
+ - **A standalone binary restarts without dropping a request.** The listening socket is never closed by a restart: under systemd socket activation the binary serves on the socket it is given (`LISTEN_FDS`), and `kill -USR2` starts the binary now on disk as a child that inherits the socket and then drains. `SIGTERM` and `SIGINT` drain too (stop accepting, `Connection: close`, exit when idle). Tuned by `SB_DRAIN_SECONDS`, `SB_DRAIN_QUIET_MS` and `SB_EXEC_PATH`. On a 2-core box, 200 swaps under load by either route served over 100,000 requests each with none failed; the old recipe reset 134 of 283,966.
38
+ - **`build --standalone --target linux-arm64`**, and `--target linux-x64` as a name for the default, build a static musl binary for an aarch64 box from any machine. uSockets, SQLite and BearSSL are built for the target and cached.
39
+ - **`build --standalone --deploy-files`** writes a `FROM scratch` Dockerfile (6.5 MB image), a systemd socket and service, a `fly.toml` and one-command `deploy-vps.sh` and `deploy-fly.sh` scripts beside the binary.
40
+
41
+ ### Changed
42
+
43
+ - The standalone docs say that several copies behind `SO_REUSEPORT` need `SB_SQLITE_EXCLUSIVE=0`, and cover restarting, targets, containers and Fly.io.
44
+
45
+ ### CI
46
+
47
+ - The macOS runners moved from `macos-14` to `macos-15` ahead of the macOS 14 retirement.
48
+
13
49
  ## [0.48.1] - 2026-10-31
14
50
 
15
51
  Same packages as 0.48.0 (`@sproutboat/runtime` 0.39.0, `@sproutboat/toolchain` 0.21.3, `@sproutboat/wire` 0.21.2). The v0.48.0 tag
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.48.1
6
+ **Package:** `sproutboat` 0.50.0
7
7
 
8
8
  ## Commands
9
9
 
@@ -13,7 +13,7 @@
13
13
  | `check` | `[project-dir]` | Validate the config and entry point without building. |
14
14
  | `types` | `[project-dir]` | 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. |
15
15
  | `dev` | `[project-dir] [--port <n>] [--no-watch]` | Run the project on this machine against a real broker, rebuilding on save. |
16
- | `build` | `[project-dir] [--target host] [--standalone] [--with-restore] [--emit-only]` | Cross-compile the native-fetch sprout (Porffor + Zig). `--target host` builds for this machine instead, to run locally, not deployable. `--standalone` emits one executable carrying its own bindings, with SQLite compiled in (~2 MB) and no broker process. `--with-restore` adds an offline `APP ltx-restore --generation DIR --output NEW_DB [--txid N]` startup command to a standalone executable; it runs before app initialization. `--emit-only` stops after writing `.sproutboat/porffor-units/<shape>/sprout.generated.js`, the module Porffor would compile, and prints its path: every check still runs, nothing is compiled. |
16
+ | `build` | `[project-dir] [--target host|linux-x64|linux-arm64] [--standalone] [--deploy-files] [--with-restore] [--emit-only]` | Cross-compile the native-fetch sprout (Porffor + Zig). `--target host` builds for this machine instead, to run locally, not deployable. `--standalone` emits one executable carrying its own bindings, with SQLite compiled in (~2 MB) and no broker process; add `--target linux-arm64` for an aarch64 box (the default is `linux-x64`) and `--deploy-files` to write a Dockerfile, a systemd socket and service, a fly.toml and a push script beside it. `--with-restore` adds an offline `APP ltx-restore --generation DIR --output NEW_DB [--txid N]` startup command to a standalone executable; it runs before app initialization. `--emit-only` stops after writing `.sproutboat/porffor-units/<shape>/sprout.generated.js`, the module Porffor would compile, and prints its path: every check still runs, nothing is compiled. |
17
17
  | `toolchain` | `doctor [--json]` | Report the selected compiler inputs, cache locations, overrides, and host prerequisites without downloading or compiling anything. |
18
18
  | `deploy` | `[project-dir] [--dry-run] [--artifact <dir>] [--no-wait] [--no-provision]` | Build (unless --artifact), auto-provision id-less storage bindings and pin their ids into sproutboat.jsonc, print the report, upload, wait until the URL serves. The control plane skips an upload that matches the live artifact byte-for-byte. --dry-run stops before upload; --no-wait skips the health check; --no-provision leaves id-less bindings as ephemeral deploy-scoped stores. |
19
19
  | `versions` | `<list | view <version-id>> [project-dir]` | List the project's deployed versions, or show one version's artifact and bindings. |
@@ -32,7 +32,7 @@
32
32
  | `complete` | `[bash | zsh | fish]` | Print a shell completion script for the top-level commands (defaults to $SHELL). |
33
33
 
34
34
  ```
35
- usage: sproutboat <init [name] | check [project-dir] | types [project-dir] | dev [project-dir] [--port <n>] [--no-watch] | build [project-dir] [--target host] [--standalone] [--with-restore] [--emit-only] | toolchain doctor [--json] | deploy [project-dir] [--dry-run] [--artifact <dir>] [--no-wait] [--no-provision] | versions <list | view <version-id>> [project-dir] | rollback <version-id> [project-dir] | tail [project-dir] [--format pretty|json] [--status ok|error|<code>|5xx] [--method GET,POST] [--sampling-rate <0..1>] [--search <text>] [--outcome <name>] [--recent] [--sprout] | kv <list | create <name> | info <name> | rename <name> <new> | delete <name>> | <key | bulk | export> ... | d1 <list | create <name> | info <name> | rename <name> <new> | delete <name>> | r2 <list | create <name> | info <name> | rename <name> <new> | delete <name>> | queues <list | create <name> | info <name> | rename <name> <new> | delete <name>> | domains <list | add <host> | verify <host> | delete <host>> [project-dir] | secrets <list | put <NAME> [--value <value>] | delete <NAME>> [project-dir] | delete [project-dir] [--name <project>] --yes | login [--api-url <url>] [--token <token>] | logout [--api-url <url>] | whoami | complete [bash | zsh | fish]>
35
+ usage: sproutboat <init [name] | check [project-dir] | types [project-dir] | dev [project-dir] [--port <n>] [--no-watch] | build [project-dir] [--target host|linux-x64|linux-arm64] [--standalone] [--deploy-files] [--with-restore] [--emit-only] | toolchain doctor [--json] | deploy [project-dir] [--dry-run] [--artifact <dir>] [--no-wait] [--no-provision] | versions <list | view <version-id>> [project-dir] | rollback <version-id> [project-dir] | tail [project-dir] [--format pretty|json] [--status ok|error|<code>|5xx] [--method GET,POST] [--sampling-rate <0..1>] [--search <text>] [--outcome <name>] [--recent] [--sprout] | kv <list | create <name> | info <name> | rename <name> <new> | delete <name>> | <key | bulk | export> ... | d1 <list | create <name> | info <name> | rename <name> <new> | delete <name>> | r2 <list | create <name> | info <name> | rename <name> <new> | delete <name>> | queues <list | create <name> | info <name> | rename <name> <new> | delete <name>> | domains <list | add <host> | verify <host> | delete <host>> [project-dir] | secrets <list | put <NAME> [--value <value>] | delete <NAME>> [project-dir] | delete [project-dir] [--name <project>] --yes | login [--api-url <url>] [--token <token>] | logout [--api-url <url>] | whoami | complete [bash | zsh | fish]>
36
36
  ```
37
37
 
38
38
  ## Environment variables
@@ -10,7 +10,7 @@ to its upstream source.
10
10
  | Zig | `0.16.0` | Acquired cross-compiler | MIT | https://ziglang.org |
11
11
  | uWebSockets and uSockets | `360c276d609d59af56ae6932adb95154ace9f15f` | Embedded source and static archive | Apache-2.0 | https://github.com/uNetworking/uWebSockets |
12
12
  | SQLite | `3.50.4` | Acquired for standalone builds | Public domain | https://sqlite.org |
13
- | BearSSL | `0.6` | Acquired for standalone builds | MIT | https://bearssl.org |
13
+ | BearSSL | `7bea48e` (master) | Acquired for standalone builds | MIT | https://bearssl.org |
14
14
 
15
15
  ## Integrity pins
16
16
 
@@ -19,7 +19,7 @@ to its upstream source.
19
19
  | Porffor source archive | `1a62187a93356b36cb6ac703ce9770c8917e4fa65545fd7c33ee8a180ae5a4c9` |
20
20
  | Embedded uWebSockets archive | `e83736f3f8cf9d56a1ebe6ea61625a7af12386763374d47c14cff472ada7484a` |
21
21
  | SQLite amalgamation archive | `1d3049dd0f830a025a53105fc79fd2ab9431aea99e137809d064d8ee8356b032` |
22
- | BearSSL source archive | `6705bba1714961b41a728dfc5debbe348d2966c117649392f8c8139efc83ff14` |
22
+ | BearSSL source archive | `ed397832b1613582737d9c1ce01c1af74ad6a9e9c52a341e24433987cda0f372` |
23
23
  | Mozilla CA bundle | `f66dff1bdf8f96060b8177976f8b7d9254bc89bc4db933d769f7384d28480bc9` |
24
24
 
25
25
  The complete Apache-2.0 notice for uWebSockets and uSockets is retained in the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sproutboat",
3
- "version": "0.48.1",
3
+ "version": "0.50.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",
@@ -64,9 +64,9 @@
64
64
  "@sproutboat/artifact": "^0.3.0",
65
65
  "@sproutboat/assets": "^0.3.0",
66
66
  "@sproutboat/config": "^0.6.0",
67
- "@sproutboat/runtime": "^0.39.0",
68
- "@sproutboat/toolchain": "^0.21.3",
69
- "@sproutboat/wire": "^0.21.2"
67
+ "@sproutboat/runtime": "^0.41.0",
68
+ "@sproutboat/toolchain": "^0.22.0",
69
+ "@sproutboat/wire": "^0.21.4"
70
70
  },
71
71
  "devDependencies": {
72
72
  "@oxlint/plugins": "1.85.0",
@@ -78,10 +78,10 @@
78
78
  "typescript": "7.0.2"
79
79
  },
80
80
  "optionalDependencies": {
81
- "@sproutboat/cli-darwin-arm64": "0.48.1",
82
- "@sproutboat/cli-darwin-x64": "0.48.1",
83
- "@sproutboat/cli-linux-arm64": "0.48.1",
84
- "@sproutboat/cli-linux-x64": "0.48.1"
81
+ "@sproutboat/cli-darwin-arm64": "0.50.0",
82
+ "@sproutboat/cli-darwin-x64": "0.50.0",
83
+ "@sproutboat/cli-linux-arm64": "0.50.0",
84
+ "@sproutboat/cli-linux-x64": "0.50.0"
85
85
  },
86
86
  "engines": {
87
87
  "node": ">=18"
package/src/bearssl.ts CHANGED
@@ -19,11 +19,19 @@ import { existsSync } from "node:fs";
19
19
  import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
20
20
  import { homedir } from "node:os";
21
21
  import { resolve } from "node:path";
22
+ import { LINUX_TARGETS, type BuildTarget } from "./targets";
22
23
  import { resolveHostCompiler } from "./toolchain";
23
24
 
24
- export const BEARSSL_VERSION = "0.6";
25
- const BEARSSL_URL = `https://bearssl.org/bearssl-${BEARSSL_VERSION}.tar.gz`;
26
- const BEARSSL_SHA256 = "6705bba1714961b41a728dfc5debbe348d2966c117649392f8c8139efc83ff14";
25
+ /**
26
+ * BearSSL master, pinned to a commit: 0.6 (2018) predates `ec_p256_m64`, the 64-bit P-256 code that
27
+ * crypto.subtle selects explicitly (ES256 sign 2.9x, verify 5.7x faster). There is no release after 0.6,
28
+ * so the source is the gitweb snapshot of this commit, pinned by the digest of its bytes.
29
+ */
30
+ export const BEARSSL_COMMIT = "7bea48e5e850ab4cafbe68d3765cdaba13a86d6f";
31
+ export const BEARSSL_VERSION = BEARSSL_COMMIT.slice(0, 7);
32
+ const BEARSSL_TREE = `BearSSL-${BEARSSL_VERSION}`;
33
+ const BEARSSL_URL = `https://bearssl.org/gitweb/?p=BearSSL;a=snapshot;h=${BEARSSL_COMMIT};sf=tgz`;
34
+ const BEARSSL_SHA256 = "ed397832b1613582737d9c1ce01c1af74ad6a9e9c52a341e24433987cda0f372";
27
35
 
28
36
  /**
29
37
  * The Mozilla root set, as curl publishes it. Pinned by hash like everything
@@ -75,7 +83,7 @@ async function withBearsslLock<T>(dir: string, name: string, action: () => Promi
75
83
  }
76
84
 
77
85
  export type BearsslInput = {
78
- target: "linux-x86_64" | "host";
86
+ target: BuildTarget;
79
87
  /** Zig binary, required for the linux target. */
80
88
  zigBin?: string;
81
89
  };
@@ -117,7 +125,7 @@ async function download(url: string, expected: string, to: string): Promise<Buff
117
125
  /** Unpack the source tree once, and build the host tools we need from it. */
118
126
  async function sourceTree(): Promise<string> {
119
127
  const dir = cacheDir();
120
- const tree = resolve(dir, `bearssl-${BEARSSL_VERSION}`);
128
+ const tree = resolve(dir, BEARSSL_TREE);
121
129
  return withBearsslLock(dir, "source", async () => {
122
130
  const tarball = resolve(dir, "bearssl.tar.gz");
123
131
  const sourceManifest = resolve(tree, ".sproutboat-source");
@@ -141,7 +149,7 @@ async function sourceTree(): Promise<string> {
141
149
  await mkdir(stage, { recursive: true });
142
150
  const untar = await run("tar", ["xzf", tarball, "-C", stage]);
143
151
  if (untar.code !== 0) throw new Error(`could not unpack BearSSL: ${untar.stderr}`);
144
- const stagedTree = resolve(stage, `bearssl-${BEARSSL_VERSION}`);
152
+ const stagedTree = resolve(stage, BEARSSL_TREE);
145
153
  if (!existsSync(resolve(stagedTree, "inc/bearssl.h"))) {
146
154
  await rm(stage, { recursive: true, force: true });
147
155
  throw new Error("BearSSL archive did not contain bearssl.h");
@@ -248,7 +256,7 @@ export async function ensureBearssl(input: BearsslInput): Promise<BearsslBuild>
248
256
  const [cc, ...prefix] =
249
257
  input.target === "host"
250
258
  ? (await resolveHostCompiler()).cc
251
- : [input.zigBin ?? "zig", "cc", "-target", "x86_64-linux-musl"];
259
+ : [input.zigBin ?? "zig", "cc", "-target", LINUX_TARGETS[input.target].triple];
252
260
  const includes = [includeDir, resolve(tree, "src")];
253
261
  const sourceFiles = await sources(tree);
254
262
  const anchors = await trustAnchorSource(tree);
package/src/build.ts CHANGED
@@ -9,6 +9,7 @@ import { sqliteStamp } from "./sqlite";
9
9
  import { bearsslStamp, ensureBearssl } from "./bearssl";
10
10
  import { compileSprout, loadPrelude, type Transport } from "./compile";
11
11
  import { compileCached } from "./compile-cache";
12
+ import type { BuildTarget } from "./targets";
12
13
  import {
13
14
  ARTIFACT_SCHEMA_VERSION,
14
15
  CAPABILITY_PROFILE,
@@ -38,7 +39,7 @@ export type BuildInput = {
38
39
  * box, so `sproutboat dev` can run the sprout locally. The manifest records
39
40
  * the real target, which is what stops the result being deployed.
40
41
  */
41
- target?: "linux-x86_64" | "host";
42
+ target?: BuildTarget;
42
43
  /** #15 — `embedded` compiles SQLite into the sprout instead of a broker
43
44
  * transport. Defaults to the broker transport. */
44
45
  transport?: Transport;
@@ -129,6 +130,8 @@ async function prepareBuild(input: BuildInput) {
129
130
  const sourceHash = digest(source);
130
131
  const target = input.target ?? "linux-x86_64";
131
132
  const embedded = input.transport === "embedded";
133
+ if (target === "linux-arm64" && !embedded)
134
+ throw new Error("linux-arm64 builds are for --standalone: the platform that `deploy` targets runs x86_64");
132
135
 
133
136
  // #74 — split each storage-binding array into its binding-name list (the
134
137
  // legacy shape the prelude/broker read) plus a `resources` map { binding ->
@@ -420,7 +423,8 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
420
423
  const manifest: ArtifactManifest & { compileMs: number } = {
421
424
  schemaVersion: ARTIFACT_SCHEMA_VERSION,
422
425
  project: input.config.name,
423
- target: host ? hostTarget() : DEPLOY_TARGET,
426
+ // An arm64 binary is never deployable (the platform runs x86_64), so its manifest says what it is.
427
+ target: host ? hostTarget() : target === "linux-arm64" ? target : DEPLOY_TARGET,
424
428
  runtime: RUNTIME,
425
429
  capabilityProfile: CAPABILITY_PROFILE,
426
430
  porfforVersion: porfforVersion(),
package/src/compile.ts CHANGED
@@ -15,7 +15,14 @@ import { homedir } from "node:os";
15
15
  import { dirname, resolve } from "node:path";
16
16
  import { ensurePorfforPatched } from "./patch-porffor";
17
17
  import { buildNativeRestore } from "./native-restore";
18
- import { ensureUWebSockets, ensureUWebSocketsHost, resolveHostCompiler, UwsUnavailableError } from "./toolchain";
18
+ import {
19
+ ensureUWebSockets,
20
+ ensureUWebSocketsHost,
21
+ ensureUWebSocketsStandalone,
22
+ resolveHostCompiler,
23
+ UwsUnavailableError,
24
+ } from "./toolchain";
25
+ import { isLinuxTarget, LINUX_TARGETS, linuxTriple, type BuildTarget } from "./targets";
19
26
  import { ensurePorffor } from "./porffor-toolchain";
20
27
  // The prelude + transports live in @sproutboat/runtime (next to wrap.ts, which
21
28
  // locates them by file URL). `preludePath`/`transportPath` read them from there
@@ -83,7 +90,7 @@ export type CompileInput = {
83
90
  * file. Anything that could be deployed stays at -O3.
84
91
  */
85
92
  optimize?: "dev" | "release";
86
- /** Cross-compiler for `linux-x86_64`. Not needed, and not used, for `host`. */
93
+ /** Cross-compiler for a Linux target. Not needed, and not used, for `host`. */
87
94
  zigBin?: string;
88
95
  /** #126 — `env.<binding> = { id, tag, timestamp }`, baked directly into the module. */
89
96
  versionMetadata?: { binding: string; id: string; tag: string; timestamp: string };
@@ -93,7 +100,7 @@ export type CompileInput = {
93
100
  * dev` can actually serve a sprout on a developer's laptop — an arm64 Mac
94
101
  * cannot execute the deploy artifact.
95
102
  */
96
- target?: "linux-x86_64" | "host";
103
+ target?: BuildTarget;
97
104
  /**
98
105
  * Write `generatedPath` exactly as a full compile would, then stop: no
99
106
  * Porffor, no uWebSockets, no compiler, nothing at `outPath`. For callers
@@ -169,10 +176,19 @@ function compileEnv(
169
176
  extraLink?: string[],
170
177
  extraCflags?: string[],
171
178
  compiler?: { cc: string[]; cxx: string[] },
179
+ linux?: { triple: string; uwsVariant: string },
172
180
  ) {
173
181
  const link = extraLink && extraLink.length > 0 ? extraLink.join(" ") : undefined;
174
182
  const cflags = extraCflags && extraCflags.length > 0 ? extraCflags.join(" ") : undefined;
175
- const env = { ...process.env, PATH: path, SB_EXTRA_LINK: link, SB_EXTRA_CFLAGS: cflags };
183
+ const env = {
184
+ ...process.env,
185
+ PATH: path,
186
+ SB_EXTRA_LINK: link,
187
+ SB_EXTRA_CFLAGS: cflags,
188
+ // The toolchain patch (patchMuslTarget) reads these; unset, Porffor builds x86_64 against its usual tree.
189
+ SB_MUSL_TARGET: linux?.triple,
190
+ SB_UWS_VARIANT: linux?.uwsVariant,
191
+ };
176
192
  if (compiler) return { ...env, CC: compiler.cc.join(" "), CXX: compiler.cxx.join(" ") };
177
193
  return env;
178
194
  }
@@ -258,9 +274,12 @@ export async function compileSprout(input: CompileInput): Promise<void> {
258
274
  // the first build needs no `git` / `make`. The two targets keep separate
259
275
  // cache trees and separate uSockets.a, so seed whichever this build wants.
260
276
  // Fall back to Porffor's own git+make path if the archive is unusable.
277
+ let uwsVariant: string | undefined;
261
278
  try {
262
279
  if (input.target === "host") await ensureUWebSocketsHost();
263
- else await ensureUWebSockets();
280
+ else if (input.transport === "embedded" && input.zigBin) {
281
+ uwsVariant = await ensureUWebSocketsStandalone(input.target ?? "linux-x86_64", input.zigBin);
282
+ } else await ensureUWebSockets();
264
283
  } catch (error) {
265
284
  if (!(error instanceof UwsUnavailableError)) throw error;
266
285
  if (process.env.SPROUTBOAT_BUILD_UWS_FROM_SOURCE !== "1") throw error;
@@ -308,8 +327,8 @@ export async function compileSprout(input: CompileInput): Promise<void> {
308
327
  directory: dirname(generatedPath),
309
328
  prelude: await loadPrelude("embedded"),
310
329
  porfforCommand: command,
311
- cc: hostCompiler?.cc ?? [input.zigBin!, "cc", "-target", "x86_64-linux-musl"],
312
- cxx: hostCompiler?.cxx ?? [input.zigBin!, "c++", "-target", "x86_64-linux-musl"],
330
+ cc: hostCompiler?.cc ?? [input.zigBin!, "cc", "-target", linuxTriple(input.target)],
331
+ cxx: hostCompiler?.cxx ?? [input.zigBin!, "c++", "-target", linuxTriple(input.target)],
313
332
  cflags: input.extraCflags ?? [],
314
333
  macho: input.target === "host" && process.platform === "darwin",
315
334
  })
@@ -326,6 +345,9 @@ export async function compileSprout(input: CompileInput): Promise<void> {
326
345
  [...(input.extraLink ?? []), ...restoreObjects],
327
346
  [...(input.extraCflags ?? []), ...(input.withRestore ? ["-Dmain=sb_app_main"] : [])],
328
347
  hostCompiler,
348
+ uwsVariant && input.target && isLinuxTarget(input.target)
349
+ ? { triple: LINUX_TARGETS[input.target].triple, uwsVariant }
350
+ : undefined,
329
351
  ),
330
352
  },
331
353
  );
package/src/main.ts CHANGED
@@ -20,6 +20,8 @@ import { controlVersionWarning } from "./api-version";
20
20
  import { amber, bold, dim, leaf, ok, rose } from "./style";
21
21
  import { pathToFileURL } from "node:url";
22
22
  import { inspectToolchain } from "./toolchain";
23
+ import { isLinuxTarget, parseTarget, TARGET_NAMES, type BuildTarget } from "./targets";
24
+ import { writeDeployFiles } from "./standalone-deploy";
23
25
  import { parseTailArgs, runTail, TAIL_USAGE, tailQuery } from "./tail";
24
26
 
25
27
  // A compiled Bun executable is not a general-purpose `bun` command. Porffor
@@ -306,7 +308,7 @@ async function refreshTypes(projectDir: string, config: SproutboatConfig): Promi
306
308
  console.log(dim(` ${TYPES_FILE} refreshed from sproutboat.jsonc`));
307
309
  }
308
310
 
309
- async function build(directory?: string, target: "linux-x86_64" | "host" = "linux-x86_64") {
311
+ async function build(directory?: string, target: BuildTarget = "linux-x86_64") {
310
312
  const project = await readProject(directory);
311
313
  console.log(
312
314
  target === "host"
@@ -338,8 +340,9 @@ async function build(directory?: string, target: "linux-x86_64" | "host" = "linu
338
340
  */
339
341
  async function buildStandaloneBinary(
340
342
  directory: string | undefined,
341
- target: "linux-x86_64" | "host",
343
+ target: BuildTarget,
342
344
  withRestore = false,
345
+ deployFiles = false,
343
346
  ) {
344
347
  const project = await readProject(directory);
345
348
  console.log(dim(`Building a standalone ${project.config.name} (${target})…`));
@@ -354,6 +357,12 @@ async function buildStandaloneBinary(
354
357
  });
355
358
  console.log(ok(`built ${result.outPath} (${(result.bytes / 1_000_000).toFixed(1)} MB)`));
356
359
  console.log(dim(` run it: ${result.outPath} · state lands in ./${project.config.name}.data`));
360
+ if (target === "linux-arm64") console.log(dim(" linux-arm64: runs on an aarch64 Linux box, not on this machine"));
361
+ if (deployFiles && isLinuxTarget(target)) {
362
+ const written = await writeDeployFiles({ name: project.config.name, target, binary: result.outPath });
363
+ console.log(ok(`deploy files in ${written.dir}`));
364
+ console.log(dim(` ${written.files.join(" · ")} · how to use them: docs/standalone.md`));
365
+ }
357
366
  } catch (error) {
358
367
  fail(error instanceof Error ? error.message : String(error));
359
368
  }
@@ -366,7 +375,7 @@ async function buildStandaloneBinary(
366
375
  * fails the build; no compiler, SQLite or BearSSL is acquired and no binary is
367
376
  * written.
368
377
  */
369
- async function emitOnly(directory: string | undefined, target: "linux-x86_64" | "host", standalone: boolean) {
378
+ async function emitOnly(directory: string | undefined, target: BuildTarget, standalone: boolean) {
370
379
  const project = await readProject(directory);
371
380
  console.log(
372
381
  dim(`Emitting ${standalone ? "the standalone " : ""}${project.config.name} source (${target}, no native compile)…`),
@@ -1370,11 +1379,28 @@ switch (command) {
1370
1379
  "--with-restore requires --standalone without --emit-only",
1371
1380
  "build --standalone --with-restore [--target host] [project-dir]",
1372
1381
  );
1373
- const hostBuild = args.includes("--target") && args[args.indexOf("--target") + 1] === "host";
1374
- const directory = args.find((arg) => !arg.startsWith("--") && arg !== "host");
1375
- const target = hostBuild ? "host" : "linux-x86_64";
1382
+ const targetAt = args.indexOf("--target");
1383
+ const targetArg = targetAt >= 0 ? args[targetAt + 1] : undefined;
1384
+ const target = targetAt < 0 ? "linux-x86_64" : parseTarget(targetArg);
1385
+ if (target === undefined)
1386
+ usageError(
1387
+ `unknown --target ${targetArg === undefined ? "(missing)" : `"${targetArg}"`}`,
1388
+ `build [--target ${TARGET_NAMES}] [project-dir]`,
1389
+ );
1390
+ const directory = args.find((arg, at) => !arg.startsWith("--") && (targetAt < 0 || at !== targetAt + 1));
1391
+ const deployFiles = args.includes("--deploy-files");
1392
+ if ((deployFiles || target === "linux-arm64") && !args.includes("--standalone"))
1393
+ usageError(
1394
+ `${deployFiles ? "--deploy-files" : "--target linux-arm64"} requires --standalone: the platform runs x86_64`,
1395
+ "build --standalone [--target linux-x64 | linux-arm64] [--deploy-files] [project-dir]",
1396
+ );
1397
+ if (deployFiles && !isLinuxTarget(target))
1398
+ usageError(
1399
+ "--deploy-files needs a Linux target",
1400
+ "build --standalone --target linux-x64 --deploy-files [project-dir]",
1401
+ );
1376
1402
  if (args.includes("--emit-only")) await emitOnly(directory, target, args.includes("--standalone"));
1377
- else if (args.includes("--standalone")) await buildStandaloneBinary(directory, target, withRestore);
1403
+ else if (args.includes("--standalone")) await buildStandaloneBinary(directory, target, withRestore, deployFiles);
1378
1404
  else await build(directory, target);
1379
1405
  break;
1380
1406
  }
package/src/sqlite.ts CHANGED
@@ -16,6 +16,7 @@ import { existsSync } from "node:fs";
16
16
  import { mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
17
17
  import { resolve } from "node:path";
18
18
  import { homedir } from "node:os";
19
+ import { LINUX_TARGETS, type BuildTarget } from "./targets";
19
20
  import { resolveHostCompiler } from "./toolchain";
20
21
 
21
22
  export const SQLITE_VERSION = "3.50.4";
@@ -134,8 +135,8 @@ async function acquireAmalgamation(dir: string): Promise<string> {
134
135
  }
135
136
 
136
137
  export type SqliteObjectInput = {
137
- /** `host` compiles with cc; `linux-x86_64` cross-compiles with the pinned Zig. */
138
- target: "linux-x86_64" | "host";
138
+ /** `host` compiles with cc; a Linux target cross-compiles with the pinned Zig. */
139
+ target: BuildTarget;
139
140
  /** Zig binary, required for the linux target. */
140
141
  zigBin?: string;
141
142
  };
@@ -151,7 +152,7 @@ export async function ensureSqliteObject(input: SqliteObjectInput): Promise<stri
151
152
  const [cmd, ...prefix] =
152
153
  input.target === "host"
153
154
  ? (await resolveHostCompiler()).cc
154
- : [input.zigBin ?? "zig", "cc", "-target", "x86_64-linux-musl"];
155
+ : [input.zigBin ?? "zig", "cc", "-target", LINUX_TARGETS[input.target].triple];
155
156
  const command = [cmd, ...prefix];
156
157
  const validCache = async (): Promise<boolean> => {
157
158
  try {
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Deploy recipes for a standalone binary: `build --standalone --deploy-files` writes them beside it.
3
+ *
4
+ * Nothing here terminates TLS. The binary is a plain HTTP server, and every recipe puts it behind
5
+ * something that owns the certificate (Fly's edge, a reverse proxy, a load balancer). The recipes are
6
+ * strings so they can be tested without a build; docs/standalone.md explains what they do.
7
+ */
8
+ import { chmod, copyFile, mkdir, writeFile } from "node:fs/promises";
9
+ import { basename, dirname, resolve } from "node:path";
10
+ import { LINUX_TARGETS, type LinuxTarget } from "./targets";
11
+
12
+ export type DeployFilesInput = {
13
+ /** The project name: the binary, unit and Fly app are all called this. */
14
+ name: string;
15
+ target: LinuxTarget;
16
+ };
17
+
18
+ /** A scratch image, so the container is the binary and nothing else. See docs/standalone.md. */
19
+ export function dockerfile(input: DeployFilesInput): string {
20
+ const { name } = input;
21
+ return `# syntax=docker/dockerfile:1
22
+ # ${LINUX_TARGETS[input.target].platform}. Build: docker build --platform ${LINUX_TARGETS[input.target].platform} -t ${name} .
23
+ # The binary is static and carries its own CA roots, so there is no base image and no libc.
24
+ FROM scratch
25
+ # 65532 is the usual unprivileged uid. data/ is an empty directory this recipe ships, so the volume
26
+ # below starts out writable by that uid without a RUN step (scratch has no shell).
27
+ COPY --chown=65532:65532 data/ /data/
28
+ COPY --chown=65532:65532 --chmod=0755 ${name} /app/${name}
29
+ ENV PORT=8080 SB_DATA_DIR=/data SB_DRAIN_SECONDS=8
30
+ EXPOSE 8080
31
+ VOLUME /data
32
+ USER 65532:65532
33
+ # SIGTERM drains: it stops accepting, answers what is in flight and exits. docker stop waits 10 s.
34
+ STOPSIGNAL SIGTERM
35
+ ENTRYPOINT ["/app/${name}"]
36
+ `;
37
+ }
38
+
39
+ export function dockerignore(): string {
40
+ return "deploy-vps.sh\ndeploy-fly.sh\n*.service\n*.socket\nfly.toml\n";
41
+ }
42
+
43
+ /** The socket systemd keeps open across a restart: the connections waiting in it are never reset. */
44
+ export function systemdSocket(input: DeployFilesInput): string {
45
+ const { name } = input;
46
+ return `[Unit]
47
+ Description=${name} (listening socket)
48
+
49
+ [Socket]
50
+ # systemd binds this and hands it to ${name}.service as fd 3. The binary serves on it instead of
51
+ # binding PORT. It stays open while the service stops and starts, so a restart resets nothing.
52
+ ListenStream=8080
53
+ Backlog=4096
54
+ # By default systemd closes the socket if more than 200 starts are triggered in 2 s, which a busy
55
+ # restart can do. Closing it is the reset this exists to avoid.
56
+ TriggerLimitIntervalSec=0
57
+
58
+ [Install]
59
+ WantedBy=sockets.target
60
+ `;
61
+ }
62
+
63
+ export function systemdService(input: DeployFilesInput): string {
64
+ const { name } = input;
65
+ return `[Unit]
66
+ Description=${name}
67
+ Requires=${name}.socket
68
+ After=network.target
69
+
70
+ [Service]
71
+ Type=simple
72
+ ExecStart=/opt/${name}/${name}
73
+ # State (SQLite files, R2 objects) lives here; systemd creates it and owns it for the dynamic user.
74
+ StateDirectory=${name}
75
+ Environment=SB_DATA_DIR=/var/lib/${name}
76
+ # Secrets and other settings, one KEY=value per line. The dash makes the file optional.
77
+ EnvironmentFile=-/etc/${name}.env
78
+ DynamicUser=yes
79
+ # A restart sends SIGTERM: the old process answers what it has accepted and exits, then systemd
80
+ # starts the new binary on the same socket. Allow it longer than SB_DRAIN_SECONDS.
81
+ KillSignal=SIGTERM
82
+ Environment=SB_DRAIN_SECONDS=20
83
+ TimeoutStopSec=30
84
+ Restart=on-failure
85
+ NoNewPrivileges=yes
86
+ ProtectSystem=strict
87
+ ProtectHome=yes
88
+ PrivateTmp=yes
89
+
90
+ [Install]
91
+ WantedBy=multi-user.target
92
+ `;
93
+ }
94
+
95
+ /** One command: first run installs the units, every later run swaps the binary. */
96
+ export function deployVpsScript(input: DeployFilesInput): string {
97
+ const { name } = input;
98
+ return `#!/bin/sh
99
+ # Usage: ./deploy-vps.sh user@host [ssh options]
100
+ # Copies the binary and the systemd units to a Linux box and restarts the service on its socket,
101
+ # so the connections waiting at the moment of the swap are served by the new binary, not reset.
102
+ # The user needs sudo on the box. TLS is yours: put nginx, Caddy or a load balancer in front of :8080.
103
+ set -eu
104
+ HOST=\${1:?usage: deploy-vps.sh user@host}
105
+ shift
106
+ HERE=$(cd "$(dirname "$0")" && pwd)
107
+ scp "$@" "$HERE/${name}" "$HERE/${name}.service" "$HERE/${name}.socket" "$HOST:/tmp/"
108
+ ssh "$@" "$HOST" 'sudo sh -eu' <<'REMOTE'
109
+ mkdir -p /opt/${name}
110
+ install -m 0755 /tmp/${name} /opt/${name}/${name}.new
111
+ mv -f /opt/${name}/${name}.new /opt/${name}/${name}
112
+ install -m 0644 /tmp/${name}.service /tmp/${name}.socket /etc/systemd/system/
113
+ rm -f /tmp/${name} /tmp/${name}.service /tmp/${name}.socket
114
+ systemctl daemon-reload
115
+ systemctl enable --now ${name}.socket
116
+ systemctl restart ${name}.service
117
+ systemctl --no-pager --lines=5 status ${name}.service
118
+ REMOTE
119
+ `;
120
+ }
121
+
122
+ export function flyToml(input: DeployFilesInput): string {
123
+ const { name } = input;
124
+ return `app = "${name}"
125
+ # Pick the region closest to your users: fly platform regions
126
+ primary_region = "iad"
127
+ kill_signal = "SIGTERM"
128
+ kill_timeout = 10
129
+
130
+ [build]
131
+ dockerfile = "Dockerfile"
132
+
133
+ [env]
134
+ PORT = "8080"
135
+ SB_DATA_DIR = "/data"
136
+ SB_DRAIN_SECONDS = "8"
137
+
138
+ # State lives on a volume. A volume attaches to one machine, so a deploy stops it and starts the new
139
+ # one and there is a few seconds with no machine. For an app with no state (no KV, D1, R2, queues or
140
+ # Durable Objects), delete this block and run two machines: Fly then replaces them one at a time.
141
+ [mounts]
142
+ source = "data"
143
+ destination = "/data"
144
+
145
+ # Fly terminates TLS at its edge and speaks plain HTTP to the binary.
146
+ [[services]]
147
+ protocol = "tcp"
148
+ internal_port = 8080
149
+ auto_stop_machines = "off"
150
+ auto_start_machines = true
151
+ min_machines_running = 1
152
+
153
+ [[services.ports]]
154
+ port = 80
155
+ handlers = ["http"]
156
+ force_https = true
157
+
158
+ [[services.ports]]
159
+ port = 443
160
+ handlers = ["tls", "http"]
161
+
162
+ [[services.tcp_checks]]
163
+ grace_period = "5s"
164
+ interval = "10s"
165
+ timeout = "2s"
166
+
167
+ [[vm]]
168
+ size = "shared-cpu-1x"
169
+ memory = "256mb"
170
+ `;
171
+ }
172
+
173
+ /** One command: creates the app and its volume the first time, then deploys. Needs a logged-in fly CLI. */
174
+ export function deployFlyScript(input: DeployFilesInput): string {
175
+ const { name } = input;
176
+ return `#!/bin/sh
177
+ # Usage: ./deploy-fly.sh [region]
178
+ # Needs the fly CLI, logged in (fly auth login). Creates the app and its 1 GB volume on the first run.
179
+ set -eu
180
+ cd "$(dirname "$0")"
181
+ REGION=\${1:-iad}
182
+ fly apps list --json | grep -q '"Name": *"${name}"' || fly apps create ${name}
183
+ fly volumes list -a ${name} --json | grep -q '"name": *"data"' || fly volumes create data -a ${name} --region "$REGION" --size 1 --yes
184
+ fly deploy -a ${name} --config fly.toml
185
+ `;
186
+ }
187
+
188
+ export type DeployFiles = { dir: string; files: string[] };
189
+
190
+ /** Write the recipes into `<binary>.deploy/`, with a copy of the binary where the Dockerfile and scp expect it. */
191
+ export async function writeDeployFiles(input: DeployFilesInput & { binary: string }): Promise<DeployFiles> {
192
+ const dir = resolve(dirname(input.binary), `${basename(input.binary)}.deploy`);
193
+ await mkdir(resolve(dir, "data"), { recursive: true });
194
+ await writeFile(resolve(dir, "data/.keep"), "");
195
+ await copyFile(input.binary, resolve(dir, input.name));
196
+ await chmod(resolve(dir, input.name), 0o755);
197
+ const text: [string, string][] = [
198
+ ["Dockerfile", dockerfile(input)],
199
+ [".dockerignore", dockerignore()],
200
+ [`${input.name}.socket`, systemdSocket(input)],
201
+ [`${input.name}.service`, systemdService(input)],
202
+ ["fly.toml", flyToml(input)],
203
+ ["deploy-vps.sh", deployVpsScript(input)],
204
+ ["deploy-fly.sh", deployFlyScript(input)],
205
+ ];
206
+ for (const [file, body] of text) {
207
+ await writeFile(resolve(dir, file), body);
208
+ if (file.endsWith(".sh")) await chmod(resolve(dir, file), 0o755);
209
+ }
210
+ return { dir, files: [input.name, ...text.map(([file]) => file).filter((file) => file !== ".dockerignore")] };
211
+ }
package/src/surface.ts CHANGED
@@ -97,10 +97,10 @@ export const COMMANDS: readonly Command[] = [
97
97
  name: "build",
98
98
  group: "Develop",
99
99
  emoji: "🔨",
100
- args: "[project-dir] [--target host] [--standalone] [--with-restore] [--emit-only]",
100
+ args: "[project-dir] [--target host|linux-x64|linux-arm64] [--standalone] [--deploy-files] [--with-restore] [--emit-only]",
101
101
  brief: "[project-dir]",
102
102
  summary:
103
- "Cross-compile the native-fetch sprout (Porffor + Zig). `--target host` builds for this machine instead, to run locally, not deployable. `--standalone` emits one executable carrying its own bindings, with SQLite compiled in (~2 MB) and no broker process. `--with-restore` adds an offline `APP ltx-restore --generation DIR --output NEW_DB [--txid N]` startup command to a standalone executable; it runs before app initialization. `--emit-only` stops after writing `.sproutboat/porffor-units/<shape>/sprout.generated.js`, the module Porffor would compile, and prints its path: every check still runs, nothing is compiled.",
103
+ "Cross-compile the native-fetch sprout (Porffor + Zig). `--target host` builds for this machine instead, to run locally, not deployable. `--standalone` emits one executable carrying its own bindings, with SQLite compiled in (~2 MB) and no broker process; add `--target linux-arm64` for an aarch64 box (the default is `linux-x64`) and `--deploy-files` to write a Dockerfile, a systemd socket and service, a fly.toml and a push script beside it. `--with-restore` adds an offline `APP ltx-restore --generation DIR --output NEW_DB [--txid N]` startup command to a standalone executable; it runs before app initialization. `--emit-only` stops after writing `.sproutboat/porffor-units/<shape>/sprout.generated.js`, the module Porffor would compile, and prints its path: every check still runs, nothing is compiled.",
104
104
  },
105
105
  {
106
106
  name: "toolchain",
package/src/targets.ts ADDED
@@ -0,0 +1,50 @@
1
+ /**
2
+ * The machines a binary can be built for.
3
+ *
4
+ * `linux-x86_64` is the deploy target (static musl, the one `deploy` accepts). `linux-arm64` is the
5
+ * same static musl build for an aarch64 box (a Raspberry Pi, Graviton, Ampere, an Apple-silicon
6
+ * Linux VM) and exists for `--standalone` only: the platform runs x86_64. `host` is this machine.
7
+ */
8
+ export type LinuxTarget = "linux-x86_64" | "linux-arm64";
9
+ export type BuildTarget = LinuxTarget | "host";
10
+
11
+ type LinuxInfo = {
12
+ /** Zig and uSockets name for the architecture. */
13
+ arch: "x86_64" | "aarch64";
14
+ /** The Zig target triple: a static binary needs no libc on the box. */
15
+ triple: string;
16
+ /** Container platform for `docker --platform`. */
17
+ platform: "linux/amd64" | "linux/arm64";
18
+ };
19
+
20
+ export const LINUX_TARGETS = {
21
+ "linux-x86_64": { arch: "x86_64", triple: "x86_64-linux-musl", platform: "linux/amd64" },
22
+ "linux-arm64": { arch: "aarch64", triple: "aarch64-linux-musl", platform: "linux/arm64" },
23
+ } satisfies Record<LinuxTarget, LinuxInfo>;
24
+
25
+ const ALIASES = new Map<string, BuildTarget>([
26
+ ["host", "host"],
27
+ ["linux-x86_64", "linux-x86_64"],
28
+ ["linux-x64", "linux-x86_64"],
29
+ ["linux-amd64", "linux-x86_64"],
30
+ ["x86_64-linux", "linux-x86_64"],
31
+ ["linux-arm64", "linux-arm64"],
32
+ ["linux-aarch64", "linux-arm64"],
33
+ ["aarch64-linux", "linux-arm64"],
34
+ ]);
35
+
36
+ /** `--target` value to a build target; undefined when it names none. */
37
+ export function parseTarget(value: string | undefined): BuildTarget | undefined {
38
+ return value === undefined ? undefined : ALIASES.get(value);
39
+ }
40
+
41
+ export const TARGET_NAMES = "host | linux-x64 | linux-arm64";
42
+
43
+ export function isLinuxTarget(target: BuildTarget): target is LinuxTarget {
44
+ return target !== "host";
45
+ }
46
+
47
+ /** The Zig triple for a Linux target; x86_64 for anything else, which is what Porffor builds by default. */
48
+ export function linuxTriple(target: BuildTarget | undefined): string {
49
+ return LINUX_TARGETS[target === "linux-arm64" ? "linux-arm64" : "linux-x86_64"].triple;
50
+ }
package/src/toolchain.ts CHANGED
@@ -18,6 +18,7 @@ import embeddedUwsArchive from "../vendor/uwebsockets-360c276d-musl.tar.xz" with
18
18
  import { BEARSSL_VERSION } from "./bearssl";
19
19
  import { cachedPorfforRoot, PORFFOR_CHANNEL, PORFFOR_COMMIT } from "./porffor-toolchain";
20
20
  import { SQLITE_VERSION } from "./sqlite";
21
+ import { LINUX_TARGETS, type LinuxTarget } from "./targets";
21
22
 
22
23
  export const ZIG_VERSION = "0.16.0";
23
24
 
@@ -695,3 +696,86 @@ function uwsCommit(): string {
695
696
  export function toolchainStamp(): string {
696
697
  return `zig-musl/${ZIG_VERSION}+porffor/${PORFFOR_COMMIT}+uws/${uwsCommit()}`;
697
698
  }
699
+
700
+ /**
701
+ * Zero-drop restart (docs/standalone.md): a standalone binary's uSockets asks the binary for an
702
+ * inherited listening socket (systemd socket activation, or the predecessor of a SIGUSR2 restart)
703
+ * before it binds one. uSockets has no such hook, so its `bsd_create_listen_socket` gets one line, the
704
+ * same one sproutd's host build adds: a weak reference to `sb_inherited_listen_fd`, which the
705
+ * toolchain's native server defines. Anchored and idempotent, like every edit here.
706
+ */
707
+ const USOCKETS_LISTEN_ANCHOR =
708
+ "LIBUS_SOCKET_DESCRIPTOR bsd_create_listen_socket(const char *host, int port, int options) {\n";
709
+ const USOCKETS_LISTEN_HOOK =
710
+ " extern int sb_inherited_listen_fd(const char *, int) __attribute__((weak));\n" +
711
+ " if (sb_inherited_listen_fd) { int inherited = sb_inherited_listen_fd(host, port); if (inherited >= 0) return inherited; }\n";
712
+ /** Bump when the hook above changes: it is part of the cache stamp, so a stale tree is rebuilt. */
713
+ const USOCKETS_HOOK_VERSION = "listen-hook-1";
714
+
715
+ export async function patchUSocketsListenHook(uSocketsDir: string): Promise<void> {
716
+ const file = resolve(uSocketsDir, "src/bsd.c");
717
+ const text = await readFile(file, "utf8");
718
+ if (text.includes("sb_inherited_listen_fd")) return;
719
+ if (text.split(USOCKETS_LISTEN_ANCHOR).length !== 2)
720
+ throw new UwsUnavailableError(`uSockets bsd.c changed: the listen-socket anchor is gone (${file})`);
721
+ await writeFile(file, text.replace(USOCKETS_LISTEN_ANCHOR, USOCKETS_LISTEN_ANCHOR + USOCKETS_LISTEN_HOOK));
722
+ }
723
+
724
+ /**
725
+ * The uWebSockets tree for a **standalone** Linux binary, built for `target`'s architecture.
726
+ *
727
+ * Same seeding as `ensureUWebSockets` (the vendored archive is a full checkout, and only
728
+ * `uSockets.a` depends on the target), but uSockets is compiled here, from the patched source, with
729
+ * the pinned Zig: for x86_64 as well as arm64, so the one recipe is the one that is tested. The tree
730
+ * lives beside the broker's (`-musl`), in `-musl-sa-<arch>`, so a deploy build keeps its unpatched
731
+ * uSockets. Porffor is pointed at it with `SB_UWS_VARIANT` (see the toolchain's patchMuslTarget).
732
+ *
733
+ * Returns the variant name to pass as `SB_UWS_VARIANT`.
734
+ */
735
+ export async function ensureUWebSocketsStandalone(target: LinuxTarget, zigBin: string): Promise<string> {
736
+ const { arch, triple } = LINUX_TARGETS[target];
737
+ const variant = `sa-${arch}`;
738
+ const commit = uwsCommitFull();
739
+ const dir = resolve(homedir(), ".cache/porffor/deps", `uWebSockets-${commit}-musl-${variant}`);
740
+ const command = [zigBin, "cc", "-target", triple, "-std=c11", "-DLIBUS_NO_SSL", "-O3", USOCKETS_HOOK_VERSION];
741
+ if (await uwsComplete(dir, command)) return variant;
742
+
743
+ await withUwsLock(dir, async () => {
744
+ if (await uwsComplete(dir, command)) return;
745
+ const vendored = process.env.SPROUTBOAT_UWS_TARBALL || uwsVendorArchive(commit.slice(0, 8));
746
+ if (!existsSync(vendored)) throw new UwsUnavailableError(`no vendored uWebSockets archive at ${vendored}`);
747
+ if (!process.env.SPROUTBOAT_UWS_TARBALL) {
748
+ const actual = await sha256File(vendored);
749
+ if (actual !== UWS_TARBALL_SHA256) {
750
+ throw new UwsUnavailableError(
751
+ `vendored uWebSockets sha256 mismatch\n expected ${UWS_TARBALL_SHA256}\n got ${actual}`,
752
+ );
753
+ }
754
+ }
755
+ await extractUws(vendored, dir);
756
+ const uSockets = resolve(dir, "uSockets");
757
+ // The archive carries a prebuilt x86_64 uSockets.a and its object; neither is this target's.
758
+ await rm(resolve(uSockets, "uSockets.a"), { force: true });
759
+ await rm(resolve(uSockets, "bsd.o"), { force: true });
760
+ await patchUSocketsListenHook(uSockets);
761
+
762
+ const sources = ["src/*.c", "src/eventing/*.c", "src/crypto/*.c", "src/io_uring/*.c"];
763
+ const build = Bun.spawn(
764
+ [
765
+ "sh",
766
+ "-c",
767
+ `"${zigBin}" cc -target ${triple} -std=c11 -Isrc -DLIBUS_NO_SSL -O3 -c ${sources.join(" ")} && ` +
768
+ `"${zigBin}" ar rcs uSockets.a *.o && rm -f *.o`,
769
+ ],
770
+ { cwd: uSockets, stdout: "pipe", stderr: "pipe" },
771
+ );
772
+ const [code, err] = await Promise.all([build.exited, new Response(build.stderr).text()]);
773
+ if (code !== 0 || !existsSync(resolve(uSockets, "uSockets.a"))) {
774
+ throw new UwsUnavailableError(
775
+ `could not build uSockets for ${triple}: ${err.trim().split("\n").slice(-3).join(" ")}`,
776
+ );
777
+ }
778
+ await writeUwsManifest(dir, command);
779
+ });
780
+ return variant;
781
+ }