sproutboat 0.47.0 → 0.49.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 +49 -0
- package/SURFACE.md +3 -3
- package/package.json +8 -8
- package/src/bearssl.ts +3 -2
- package/src/build.ts +6 -2
- package/src/compile.ts +29 -7
- package/src/main.ts +33 -7
- package/src/sqlite.ts +4 -3
- package/src/standalone-deploy.ts +211 -0
- package/src/surface.ts +2 -2
- package/src/targets.ts +50 -0
- package/src/toolchain.ts +84 -0
package/CHANGELOG.md
CHANGED
|
@@ -10,6 +10,55 @@ maintained going forward by the `release` skill.
|
|
|
10
10
|
|
|
11
11
|
## Unreleased
|
|
12
12
|
|
|
13
|
+
## [0.49.0] - 2026-10-31
|
|
14
|
+
|
|
15
|
+
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.
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- **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.
|
|
20
|
+
- **`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.
|
|
21
|
+
- **`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.
|
|
22
|
+
|
|
23
|
+
### Changed
|
|
24
|
+
|
|
25
|
+
- The standalone docs say that several copies behind `SO_REUSEPORT` need `SB_SQLITE_EXCLUSIVE=0`, and cover restarting, targets, containers and Fly.io.
|
|
26
|
+
|
|
27
|
+
### CI
|
|
28
|
+
|
|
29
|
+
- The macOS runners moved from `macos-14` to `macos-15` ahead of the macOS 14 retirement.
|
|
30
|
+
|
|
31
|
+
## [0.48.1] - 2026-10-31
|
|
32
|
+
|
|
33
|
+
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
|
|
34
|
+
published the four platform packages but not `sproutboat` itself, because the release job's test run timed out; 0.48.1 publishes all
|
|
35
|
+
five at one version.
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- **The release test run no longer times out**: the native restore test builds two standalone binaries cold, which took 476 s on CI at
|
|
40
|
+
0.47.0 and over 600 s at 0.48.0 as the prelude grew; its limit is now 1,200 s.
|
|
41
|
+
|
|
42
|
+
## [0.48.0] - 2026-10-30
|
|
43
|
+
|
|
44
|
+
Built on `@sproutboat/runtime` 0.39.0, `@sproutboat/toolchain` 0.21.3 and `@sproutboat/wire` 0.21.2. Each change below that an
|
|
45
|
+
existing app could observe starts at a new compatibility date; earlier dates answer as before.
|
|
46
|
+
|
|
47
|
+
### Added
|
|
48
|
+
|
|
49
|
+
- **Database clients that need only `fetch` or WebSocket build and run unmodified from `2026-11-15`** (`@libsql/client`,
|
|
50
|
+
`@neondatabase/serverless`, `@supabase/supabase-js`, `@upstash/redis`, `@planetscale/database`; 113 of 117 comparisons with workerd
|
|
51
|
+
match, the rest being the wording of a network error). The build check for Node, Bun and Deno globals skips a use behind a
|
|
52
|
+
`typeof` test, a global the bundle declares itself, and mentions in strings and comments; `Error.captureStackTrace` exists.
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
|
|
56
|
+
- **`fetch()` on a standalone binary returns a promise from `2026-10-22`**, so `fetch(url).then(...)` works and a refused request
|
|
57
|
+
rejects.
|
|
58
|
+
- **A fetched 204, 205 or 304 response** no longer throws "Response with null body status cannot have body".
|
|
59
|
+
- **A `Uint8Array` subclass that redefines a builtin method** (a `Buffer` polyfill's `toString("hex")`) reaches its own method.
|
|
60
|
+
- **A collector crash (SIGBUS)** when an object grew (toolchain 0.21.3).
|
|
61
|
+
|
|
13
62
|
## [0.47.0] - 2026-10-30
|
|
14
63
|
|
|
15
64
|
Built on `@sproutboat/runtime` 0.38.0, `@sproutboat/toolchain` 0.21.1 and `@sproutboat/wire` 0.21.1.
|
package/SURFACE.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
> Generated by `src/surface.test.ts` from `src/surface.ts` + the pinned
|
|
4
4
|
> toolchain constants. Do not edit by hand — run `UPDATE_SURFACE=1 bun test`.
|
|
5
5
|
|
|
6
|
-
**Package:** `sproutboat` 0.
|
|
6
|
+
**Package:** `sproutboat` 0.49.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
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sproutboat",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.49.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.
|
|
68
|
-
"@sproutboat/toolchain": "^0.
|
|
69
|
-
"@sproutboat/wire": "^0.21.
|
|
67
|
+
"@sproutboat/runtime": "^0.39.0",
|
|
68
|
+
"@sproutboat/toolchain": "^0.22.0",
|
|
69
|
+
"@sproutboat/wire": "^0.21.2"
|
|
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.
|
|
82
|
-
"@sproutboat/cli-darwin-x64": "0.
|
|
83
|
-
"@sproutboat/cli-linux-arm64": "0.
|
|
84
|
-
"@sproutboat/cli-linux-x64": "0.
|
|
81
|
+
"@sproutboat/cli-darwin-arm64": "0.49.0",
|
|
82
|
+
"@sproutboat/cli-darwin-x64": "0.49.0",
|
|
83
|
+
"@sproutboat/cli-linux-arm64": "0.49.0",
|
|
84
|
+
"@sproutboat/cli-linux-x64": "0.49.0"
|
|
85
85
|
},
|
|
86
86
|
"engines": {
|
|
87
87
|
"node": ">=18"
|
package/src/bearssl.ts
CHANGED
|
@@ -19,6 +19,7 @@ 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
25
|
export const BEARSSL_VERSION = "0.6";
|
|
@@ -75,7 +76,7 @@ async function withBearsslLock<T>(dir: string, name: string, action: () => Promi
|
|
|
75
76
|
}
|
|
76
77
|
|
|
77
78
|
export type BearsslInput = {
|
|
78
|
-
target:
|
|
79
|
+
target: BuildTarget;
|
|
79
80
|
/** Zig binary, required for the linux target. */
|
|
80
81
|
zigBin?: string;
|
|
81
82
|
};
|
|
@@ -248,7 +249,7 @@ export async function ensureBearssl(input: BearsslInput): Promise<BearsslBuild>
|
|
|
248
249
|
const [cc, ...prefix] =
|
|
249
250
|
input.target === "host"
|
|
250
251
|
? (await resolveHostCompiler()).cc
|
|
251
|
-
: [input.zigBin ?? "zig", "cc", "-target",
|
|
252
|
+
: [input.zigBin ?? "zig", "cc", "-target", LINUX_TARGETS[input.target].triple];
|
|
252
253
|
const includes = [includeDir, resolve(tree, "src")];
|
|
253
254
|
const sourceFiles = await sources(tree);
|
|
254
255
|
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?:
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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?:
|
|
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 = {
|
|
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
|
|
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",
|
|
312
|
-
cxx: hostCompiler?.cxx ?? [input.zigBin!, "c++", "-target",
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
1374
|
-
const
|
|
1375
|
-
const target =
|
|
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;
|
|
138
|
-
target:
|
|
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",
|
|
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
|
+
}
|