sproutboat 0.20.0 → 0.22.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,56 @@ maintained going forward by the `release` skill.
10
10
 
11
11
  ## Unreleased
12
12
 
13
+ ## [0.22.0] - 2026-10-22
14
+
15
+ ### Added
16
+
17
+ - `sproutboat build --standalone --emit-only` writes
18
+ `.sproutboat/porffor-units/linux-x86_64-embedded-release/sprout.generated.js`,
19
+ byte for byte what a full standalone build compiles, prints its path and
20
+ stops. It runs every check that can fail a build, but acquires no Zig,
21
+ SQLite, BearSSL or Porffor and writes no binary, so it takes well under a
22
+ second where a full build takes tens of seconds. Without `--standalone` it
23
+ emits the broker-transport module the same way.
24
+
25
+ ### Fixed
26
+
27
+ - The `d1`, `queue` and `analytics` examples read the request body with
28
+ `await request.text()` rather than as `request.body`. From compatibility date
29
+ `2026-10-21`, `request.body` is a `ReadableStream`, as in Workers, so
30
+ `JSON.parse(request.body)` threw `SyntaxError: Unexpected token` and a D1
31
+ example moved to a current date answered every POST with a 500. The new form
32
+ works at every date. `bun run examples` now also builds the d1 example at
33
+ `2026-10-22` (`d1@2026-10-22`), so an example that only works before that
34
+ date fails the run.
35
+
36
+ ### Changed
37
+
38
+ - `@sproutboat/toolchain` 0.12.1: heap-backed BigInts stay alive through
39
+ conservative garbage collection, which fixes crashes and wrong checksums in
40
+ BigInt-heavy code such as CRC64 and LTX encoding. `@sproutboat/wire` 0.14.1.
41
+
42
+ ## [0.21.0] - 2026-10-21
43
+
44
+ ### Changed
45
+
46
+ - Apps compile with Porffor Alpha 19 (`@sproutboat/toolchain` 0.12.0).
47
+ Binaries are about 1.5% smaller, and build times are unchanged.
48
+ - Alpha 19 brings JavaScript closer to V8, and the changes apply at every
49
+ compatibility date:
50
+ - `Error.prototype.stack` is an accessor;
51
+ - `instanceof` follows the spec, including `Symbol.hasInstance`;
52
+ - sloppy-mode functions box a primitive `this`;
53
+ - undeclared names are read from the global object;
54
+ - using a `let`, `const` or class binding before its declaration throws.
55
+
56
+ ### Fixed
57
+
58
+ - `JSON.parse` with a reviver now gives the reviver the right `context.source`
59
+ when the input contains non-ASCII text.
60
+ - `Map.groupBy` works, and private class fields no longer show up as
61
+ enumerable properties.
62
+
13
63
  ## [0.20.0] - 2026-10-21
14
64
 
15
65
  ### Added
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.20.0
6
+ **Package:** `sproutboat` 0.22.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]` | 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. |
16
+ | `build` | `[project-dir] [--target host] [--standalone] [--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. `--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] | 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] [--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] [--standalone] [--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] [--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
@@ -76,7 +76,7 @@ usage: sproutboat <init [name] | check [project-dir] | types [project-dir] | dev
76
76
  | | |
77
77
  | --- | --- |
78
78
  | Zig | `0.16.0` (`zig cc -target x86_64-linux-musl`, static) |
79
- | Provenance stamp | `zig-musl/0.16.0+porffor/deda494+uws/360c276d` |
79
+ | Provenance stamp | `zig-musl/0.16.0+porffor/0c135ce+uws/360c276d` |
80
80
  | Artifact schema | `2` |
81
81
  | Runtime | `native-fetch` |
82
82
  | Capability profile | `http-sync-v0` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sproutboat",
3
- "version": "0.20.0",
3
+ "version": "0.22.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",
@@ -65,8 +65,8 @@
65
65
  "@sproutboat/assets": "^0.3.0",
66
66
  "@sproutboat/config": "^0.5.0",
67
67
  "@sproutboat/runtime": "^0.21.1",
68
- "@sproutboat/toolchain": "^0.11.0",
69
- "@sproutboat/wire": "^0.14.0"
68
+ "@sproutboat/toolchain": "^0.12.1",
69
+ "@sproutboat/wire": "^0.14.1"
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.20.0",
82
- "@sproutboat/cli-darwin-x64": "0.20.0",
83
- "@sproutboat/cli-linux-arm64": "0.20.0",
84
- "@sproutboat/cli-linux-x64": "0.20.0"
81
+ "@sproutboat/cli-darwin-arm64": "0.22.0",
82
+ "@sproutboat/cli-darwin-x64": "0.22.0",
83
+ "@sproutboat/cli-linux-arm64": "0.22.0",
84
+ "@sproutboat/cli-linux-x64": "0.22.0"
85
85
  },
86
86
  "engines": {
87
87
  "node": ">=18"
@@ -0,0 +1,193 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Run a command against local `@sproutboat/{runtime,toolchain,wire}` checkouts.
4
+ *
5
+ * bun scripts/with-local-packages.ts <path-to-sproutboat-packages> [--only runtime,toolchain,wire] -- <command...>
6
+ *
7
+ * Moves each `node_modules/@sproutboat/<pkg>` aside, symlinks
8
+ * `<packages>/packages/<pkg>` in its place, runs the command with inherited
9
+ * stdio, then puts the original entries back. The restore runs whatever the
10
+ * command does: a non-zero exit, a throw here, SIGINT or SIGTERM. Only SIGKILL
11
+ * can skip it, and the next run repairs that before it starts (the moved-aside
12
+ * entry is left in a known place for exactly that reason).
13
+ *
14
+ * Refuses to run in the main checkout when it has uncommitted changes. Work in
15
+ * progress lives there, and anything already running from it (a dev server, a
16
+ * watcher) would silently pick up the linked packages. Use a worktree instead.
17
+ */
18
+ import { existsSync, lstatSync, readlinkSync, renameSync, rmSync, symlinkSync } from "node:fs";
19
+ import { resolve } from "node:path";
20
+
21
+ const PACKAGES = ["runtime", "toolchain", "wire"] as const;
22
+ type Pkg = (typeof PACKAGES)[number];
23
+ const isPkg = (value: string): value is Pkg => PACKAGES.some((pkg) => pkg === value);
24
+
25
+ const USAGE =
26
+ "usage: bun scripts/with-local-packages.ts <path-to-sproutboat-packages> [--only runtime,toolchain,wire] -- <command...>";
27
+
28
+ function fail(message: string): never {
29
+ console.error(`with-local-packages: ${message}`);
30
+ process.exit(2);
31
+ }
32
+
33
+ // ---- arguments ----------------------------------------------------------
34
+
35
+ const argv = process.argv.slice(2);
36
+ const separator = argv.indexOf("--");
37
+ if (separator === -1) fail(`missing "--" before the command\n${USAGE}`);
38
+ const ours = argv.slice(0, separator);
39
+ const command = argv.slice(separator + 1);
40
+ if (command.length === 0) fail(`no command after "--"\n${USAGE}`);
41
+
42
+ let packagesRoot: string | undefined;
43
+ let only: Pkg[] | undefined;
44
+ for (let i = 0; i < ours.length; i++) {
45
+ const arg = ours[i];
46
+ if (arg === "--only" || arg.startsWith("--only=")) {
47
+ const value = arg === "--only" ? ours[++i] : arg.slice("--only=".length);
48
+ if (!value) fail(`--only needs a value\n${USAGE}`);
49
+ const names = value.split(",").map((name) => name.trim());
50
+ const unknown = names.filter((name) => !isPkg(name));
51
+ if (unknown.length) fail(`unknown package ${unknown.join(", ")} (expected ${PACKAGES.join(", ")})`);
52
+ only = names.filter(isPkg);
53
+ } else if (arg === "-h" || arg === "--help") {
54
+ console.log(USAGE);
55
+ process.exit(0);
56
+ } else if (arg.startsWith("-")) {
57
+ fail(`unknown option ${arg}\n${USAGE}`);
58
+ } else if (packagesRoot === undefined) {
59
+ packagesRoot = resolve(arg);
60
+ } else {
61
+ fail(`unexpected argument ${arg}\n${USAGE}`);
62
+ }
63
+ }
64
+ if (!packagesRoot) fail(`missing the path to a sproutboat-packages checkout\n${USAGE}`);
65
+ if (!existsSync(resolve(packagesRoot, "packages")))
66
+ fail(`${packagesRoot} has no packages/ directory: is it a sproutboat-packages checkout?`);
67
+
68
+ // ---- safety: never touch a dirty main checkout --------------------------
69
+
70
+ const cliRoot = resolve(import.meta.dir, "..");
71
+ function git(...args: string[]): string {
72
+ const result = Bun.spawnSync(["git", "-C", cliRoot, ...args], { stdout: "pipe", stderr: "pipe" });
73
+ if (result.exitCode !== 0) fail(`git ${args.join(" ")} failed: ${result.stderr.toString().trim()}`);
74
+ return result.stdout.toString().trim();
75
+ }
76
+ const gitDir = resolve(cliRoot, git("rev-parse", "--git-dir"));
77
+ const commonDir = resolve(cliRoot, git("rev-parse", "--git-common-dir"));
78
+ const linkedWorktree = gitDir !== commonDir;
79
+ if (!linkedWorktree && git("status", "--porcelain") !== "") {
80
+ fail(
81
+ `refusing to run: ${cliRoot} is the main checkout and has uncommitted changes.\n` +
82
+ "Relinking its node_modules would change what that work runs against. Use a worktree:\n" +
83
+ ` git -C ${cliRoot} worktree add /tmp/sb-cli-local origin/main\n` +
84
+ " cd /tmp/sb-cli-local && bun install --frozen-lockfile\n" +
85
+ ` bun scripts/with-local-packages.ts ${packagesRoot} -- <command...>`,
86
+ );
87
+ }
88
+
89
+ // ---- link ---------------------------------------------------------------
90
+
91
+ const scope = resolve(cliRoot, "node_modules/@sproutboat");
92
+ const entryPath = (pkg: Pkg) => resolve(scope, pkg);
93
+ const backupPath = (pkg: Pkg) => resolve(scope, `.${pkg}.with-local-packages-backup`);
94
+ const exists = (path: string) => {
95
+ try {
96
+ lstatSync(path);
97
+ return true;
98
+ } catch {
99
+ return false;
100
+ }
101
+ };
102
+
103
+ /** Put one package's original entry back. Synchronous so the exit hook can use it. */
104
+ function restore(pkg: Pkg): void {
105
+ const entry = entryPath(pkg);
106
+ const backup = backupPath(pkg);
107
+ if (!exists(backup)) return;
108
+ if (exists(entry)) {
109
+ // Only ever remove our own symlink, never a real directory.
110
+ if (!lstatSync(entry).isSymbolicLink()) {
111
+ console.error(`with-local-packages: ${entry} is not a symlink, leaving ${backup} for you to inspect`);
112
+ return;
113
+ }
114
+ rmSync(entry);
115
+ }
116
+ renameSync(backup, entry);
117
+ }
118
+
119
+ // A run killed with SIGKILL leaves a backup behind: repair that first.
120
+ for (const pkg of PACKAGES) {
121
+ if (exists(backupPath(pkg))) {
122
+ console.error(`with-local-packages: restoring node_modules/@sproutboat/${pkg} left over from an interrupted run`);
123
+ restore(pkg);
124
+ }
125
+ }
126
+
127
+ const requested = only ?? PACKAGES.filter((pkg) => existsSync(resolve(packagesRoot!, "packages", pkg, "package.json")));
128
+ if (requested.length === 0) fail(`no runtime, toolchain or wire package under ${packagesRoot}/packages`);
129
+ for (const pkg of requested) {
130
+ if (!existsSync(resolve(packagesRoot, "packages", pkg, "package.json")))
131
+ fail(`${packagesRoot}/packages/${pkg} has no package.json`);
132
+ if (!exists(entryPath(pkg))) fail(`node_modules/@sproutboat/${pkg} is missing: run bun install first`);
133
+ }
134
+ if (!existsSync(resolve(packagesRoot, "node_modules")))
135
+ console.error(
136
+ `with-local-packages: warning: ${packagesRoot} has no node_modules; ` +
137
+ "the linked packages resolve their own imports from there, so run bun install in it if anything fails to resolve",
138
+ );
139
+
140
+ const linked: Pkg[] = [];
141
+ let restored = false;
142
+ function restoreAll(): void {
143
+ if (restored) return;
144
+ restored = true;
145
+ for (const pkg of linked.toReversed()) {
146
+ try {
147
+ restore(pkg);
148
+ } catch (error) {
149
+ console.error(`with-local-packages: could not restore ${pkg}: ${String(error)}`);
150
+ process.exitCode = 1;
151
+ }
152
+ }
153
+ const leftover = linked.filter((pkg) => exists(backupPath(pkg)));
154
+ if (leftover.length === 0) console.error(`with-local-packages: restored ${linked.join(", ")}`);
155
+ }
156
+ // The last line of defence: runs on any exit this process gets to see.
157
+ process.on("exit", restoreAll);
158
+
159
+ let child: ReturnType<typeof Bun.spawn> | undefined;
160
+ let signalled: NodeJS.Signals | undefined;
161
+ for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"] as const) {
162
+ process.on(signal, () => {
163
+ // A second signal means "stop now": kill the command, the restore still runs.
164
+ if (signalled && child) child.kill("SIGKILL");
165
+ signalled = signal;
166
+ // Ctrl-C already reached the command through the process group; SIGTERM
167
+ // and SIGHUP sent to this process alone are passed on.
168
+ if (child && signal !== "SIGINT") child.kill(signal);
169
+ if (!child) {
170
+ restoreAll();
171
+ process.exit(128 + (signal === "SIGINT" ? 2 : signal === "SIGTERM" ? 15 : 1));
172
+ }
173
+ });
174
+ }
175
+
176
+ try {
177
+ for (const pkg of requested) {
178
+ const target = resolve(packagesRoot, "packages", pkg);
179
+ renameSync(entryPath(pkg), backupPath(pkg));
180
+ linked.push(pkg);
181
+ symlinkSync(target, entryPath(pkg), "dir");
182
+ console.error(`with-local-packages: @sproutboat/${pkg} -> ${readlinkSync(entryPath(pkg))}`);
183
+ }
184
+
185
+ child = Bun.spawn(command, { cwd: process.cwd(), stdio: ["inherit", "inherit", "inherit"] });
186
+ const code = await child.exited;
187
+ restoreAll();
188
+ if (signalled) process.exit(128 + (signalled === "SIGINT" ? 2 : signalled === "SIGTERM" ? 15 : 1));
189
+ process.exit(child.signalCode ? 1 : code);
190
+ } catch (error) {
191
+ restoreAll();
192
+ fail(error instanceof Error ? error.message : String(error));
193
+ }
package/src/build.ts CHANGED
@@ -111,12 +111,13 @@ async function acquireArtifactLock(artifactDir: string): Promise<string> {
111
111
  }
112
112
 
113
113
  /**
114
- * Cross-compile a handler into a linux-x86_64 native-fetch server binary with
115
- * Porffor + Zig — no Docker. The binary is not run here (the build host may not
116
- * be linux); the control plane starts it once on deploy and rejects it if it
117
- * does not come up.
114
+ * Everything a build derives before the native compile: bindings, assets,
115
+ * version metadata and the wrapped module itself. Every check that can fail a
116
+ * build on its source or config (a missing asset dir, oversized baked assets,
117
+ * a handler without a default export) runs here, so `emitGenerated` fails
118
+ * exactly where `buildArtifact` would.
118
119
  */
119
- export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
120
+ async function prepareBuild(input: BuildInput) {
120
121
  const source = input.source ?? (await readFile(input.sourcePath));
121
122
  const sourceHash = digest(source);
122
123
  const target = input.target ?? "linux-x86_64";
@@ -220,6 +221,93 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
220
221
  input.transport,
221
222
  versionMetadata,
222
223
  );
224
+ return {
225
+ sourceHash,
226
+ target,
227
+ embedded,
228
+ host,
229
+ bindings,
230
+ assetDir,
231
+ assetManifest,
232
+ bakedAssets,
233
+ builtAt,
234
+ versionId,
235
+ versionMetadata,
236
+ generatedSource,
237
+ };
238
+ }
239
+
240
+ /**
241
+ * Porffor caches compiled C units per entry path, so compile from one path per
242
+ * project and build shape: unchanged units (the uWebSockets server alone is
243
+ * ~1.75 s of clang) are reused instead of rebuilt, and Porffor keeps one build
244
+ * dir per shape instead of one per artifact. `sprout.generated.js` lives here.
245
+ */
246
+ function unitDirFor(input: BuildInput): string {
247
+ return resolve(
248
+ input.projectDir,
249
+ ".sproutboat/porffor-units",
250
+ `${input.target ?? "linux-x86_64"}-${input.transport ?? "broker"}-${input.optimize ?? "release"}`,
251
+ );
252
+ }
253
+
254
+ /**
255
+ * Write the wrapped module a build would compile, at the same path, and stop.
256
+ *
257
+ * Runs every check `buildArtifact` runs before compiling, then hands
258
+ * `compileSprout` `emitOnly`: no Zig, SQLite, BearSSL, Porffor or uWebSockets
259
+ * is acquired, nothing is compiled, and no artifact directory is written.
260
+ */
261
+ export async function emitGenerated(input: BuildInput): Promise<{ generatedPath: string }> {
262
+ const { bindings, bakedAssets, versionMetadata, generatedSource } = await prepareBuild(input);
263
+ const unitDir = unitDirFor(input);
264
+ const generatedPath = input.generatedPath ?? resolve(unitDir, "sprout.generated.js");
265
+ // Same lock as a full build, so an emit never races a compile reading the file.
266
+ const unitLock = input.generatedPath ? null : await acquireArtifactLock(unitDir);
267
+ try {
268
+ await compileSprout({
269
+ sourcePath: input.sourcePath,
270
+ generatedPath,
271
+ generatedSource,
272
+ outPath: resolve(unitDir, "sprout"),
273
+ vars: input.config.vars ?? {},
274
+ bindings,
275
+ target: input.target,
276
+ compatibilityDate: input.config.compatibility_date,
277
+ transport: input.transport,
278
+ appName: input.config.name,
279
+ assets: bakedAssets,
280
+ optimize: input.optimize,
281
+ versionMetadata,
282
+ emitOnly: true,
283
+ });
284
+ } finally {
285
+ if (unitLock) await rm(unitLock, { recursive: true, force: true });
286
+ }
287
+ return { generatedPath };
288
+ }
289
+
290
+ /**
291
+ * Cross-compile a handler into a linux-x86_64 native-fetch server binary with
292
+ * Porffor + Zig — no Docker. The binary is not run here (the build host may not
293
+ * be linux); the control plane starts it once on deploy and rejects it if it
294
+ * does not come up.
295
+ */
296
+ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
297
+ const {
298
+ sourceHash,
299
+ target,
300
+ embedded,
301
+ host,
302
+ bindings,
303
+ assetDir,
304
+ assetManifest,
305
+ bakedAssets,
306
+ builtAt,
307
+ versionId,
308
+ versionMetadata,
309
+ generatedSource,
310
+ } = await prepareBuild(input);
223
311
  const compileKey = digest(
224
312
  JSON.stringify({
225
313
  generatedSource,
@@ -248,15 +336,7 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
248
336
  const [sqliteObject, tls] = embedded
249
337
  ? await Promise.all([ensureSqliteObject({ target, zigBin }), ensureBearssl({ target, zigBin })])
250
338
  : [null, null];
251
- // Porffor caches compiled C units per entry path, so compile from one
252
- // path per project and build shape: unchanged units (the uWebSockets
253
- // server alone is ~1.75 s of clang) are reused instead of rebuilt, and
254
- // Porffor keeps one build dir per shape instead of one per artifact.
255
- const unitDir = resolve(
256
- input.projectDir,
257
- ".sproutboat/porffor-units",
258
- `${input.target ?? "linux-x86_64"}-${input.transport ?? "broker"}-${input.optimize ?? "release"}`,
259
- );
339
+ const unitDir = unitDirFor(input);
260
340
  const unitLock = input.generatedPath ? null : await acquireArtifactLock(unitDir);
261
341
  try {
262
342
  await compileSprout({
package/src/compile.ts CHANGED
@@ -93,6 +93,13 @@ export type CompileInput = {
93
93
  * cannot execute the deploy artifact.
94
94
  */
95
95
  target?: "linux-x86_64" | "host";
96
+ /**
97
+ * Write `generatedPath` exactly as a full compile would, then stop: no
98
+ * Porffor, no uWebSockets, no compiler, nothing at `outPath`. For callers
99
+ * that need the wrapped module (prelude, bindings, handler) and never run the
100
+ * binary, such as a qualification run that only inspects the source.
101
+ */
102
+ emitOnly?: boolean;
96
103
  };
97
104
 
98
105
  /**
@@ -197,8 +204,48 @@ export function porfforArgs(
197
204
  return args;
198
205
  }
199
206
 
200
- /** Compile `sourcePath` to a native binary at `outPath` (mode 0555). */
207
+ /** The wrapped module the compiler sees: `generatedSource` if given, else the source wrapped here. */
208
+ async function generateSource(input: CompileInput): Promise<string> {
209
+ if (input.generatedSource !== undefined) return input.generatedSource;
210
+ const [source, prelude] = await Promise.all([
211
+ input.source === undefined ? readFile(input.sourcePath, "utf8") : Promise.resolve(input.source),
212
+ loadPrelude(input.transport ?? "broker"),
213
+ ]);
214
+ return wrapNativeFetchHandler(
215
+ source,
216
+ prelude,
217
+ input.vars,
218
+ input.bindings ?? EMPTY_BINDINGS,
219
+ undefined,
220
+ input.compatibilityDate,
221
+ input.appName,
222
+ input.assets,
223
+ input.transport,
224
+ input.versionMetadata,
225
+ );
226
+ }
227
+
228
+ /** Write the wrapped module to `generatedPath` (default: beside `outPath`) and return that path. */
229
+ async function writeGenerated(input: CompileInput): Promise<string> {
230
+ const generatedPath = input.generatedPath ?? resolve(dirname(input.outPath), "sprout.generated.js");
231
+ const generatedSource = await generateSource(input);
232
+ await mkdir(dirname(generatedPath), { recursive: true });
233
+ await writeFile(generatedPath, generatedSource);
234
+ return generatedPath;
235
+ }
236
+
237
+ /**
238
+ * Compile `sourcePath` to a native binary at `outPath` (mode 0555).
239
+ *
240
+ * With `emitOnly`, write `generatedPath` and return before any of the native
241
+ * compile's inputs are acquired. Generation needs only the runtime prelude and
242
+ * the wrapper, never Porffor itself.
243
+ */
201
244
  export async function compileSprout(input: CompileInput): Promise<void> {
245
+ if (input.emitOnly) {
246
+ await writeGenerated(input);
247
+ return;
248
+ }
202
249
  const porffor = await ensurePorffor();
203
250
  await ensurePorfforPatched(porffor);
204
251
 
@@ -238,29 +285,7 @@ export async function compileSprout(input: CompileInput): Promise<void> {
238
285
  // OS may still be executing. The linker cannot overwrite either, so clear it
239
286
  // first rather than failing with "can't write output file".
240
287
  await rm(input.outPath, { force: true });
241
- const generatedPath = input.generatedPath ?? resolve(outDir, "sprout.generated.js");
242
- await mkdir(dirname(generatedPath), { recursive: true });
243
- const generatedSource =
244
- input.generatedSource ??
245
- (await (async () => {
246
- const [source, prelude] = await Promise.all([
247
- input.source === undefined ? readFile(input.sourcePath, "utf8") : Promise.resolve(input.source),
248
- loadPrelude(input.transport ?? "broker"),
249
- ]);
250
- return wrapNativeFetchHandler(
251
- source,
252
- prelude,
253
- input.vars,
254
- input.bindings ?? EMPTY_BINDINGS,
255
- undefined,
256
- input.compatibilityDate,
257
- input.appName,
258
- input.assets,
259
- input.transport,
260
- input.versionMetadata,
261
- );
262
- })());
263
- await writeFile(generatedPath, generatedSource);
288
+ const generatedPath = await writeGenerated(input);
264
289
 
265
290
  const launcher = resolve(porffor, "runtime/index.js");
266
291
  // A musl build shells `zig` bare (Porffor hardcodes the literal command), so
package/src/main.ts CHANGED
@@ -6,11 +6,11 @@ import { isBoolean, isSafeInteger, isString, jsonObject, parseJsonValue, type Js
6
6
  import type { AssetFiles } from "./assets";
7
7
  import { parseConfig, pinBindingId, resourceRefs, type SproutboatConfig } from "./config";
8
8
  import { validateHttpSyncSource } from "./source";
9
- import { buildArtifact } from "./build";
9
+ import { buildArtifact, emitGenerated } from "./build";
10
10
  import { generateTypes, TYPES_FILE } from "./types-gen";
11
11
  import { bundleHandler, BundleError, type BundleResult } from "./bundle";
12
12
  import { runDev } from "./dev";
13
- import { buildStandalone } from "./standalone-build";
13
+ import { buildStandalone, emitStandalone } from "./standalone-build";
14
14
  import { hostTarget, validateManifest, type ArtifactManifest } from "./manifest";
15
15
  import { CLI_VERSION, printDeployReport } from "./report";
16
16
  import { activeApiUrl, forgetToken, savedToken, saveToken } from "./credentials";
@@ -353,6 +353,33 @@ async function buildStandaloneBinary(directory: string | undefined, target: "lin
353
353
  }
354
354
  }
355
355
 
356
+ /**
357
+ * `build --emit-only`: run the build up to and including writing
358
+ * `sprout.generated.js` (prelude, bindings and handler, exactly what Porffor
359
+ * would compile), print its path and stop. Every source and config check still
360
+ * fails the build; no compiler, SQLite or BearSSL is acquired and no binary is
361
+ * written.
362
+ */
363
+ async function emitOnly(directory: string | undefined, target: "linux-x86_64" | "host", standalone: boolean) {
364
+ const project = await readProject(directory);
365
+ console.log(
366
+ dim(`Emitting ${standalone ? "the standalone " : ""}${project.config.name} source (${target}, no native compile)…`),
367
+ );
368
+ try {
369
+ const input = {
370
+ projectDir: project.directory,
371
+ config: project.config,
372
+ sourcePath: project.sourcePath,
373
+ source: project.bundle.code,
374
+ target,
375
+ };
376
+ const { generatedPath } = standalone ? await emitStandalone(input) : await emitGenerated(input);
377
+ console.log(generatedPath);
378
+ } catch (error) {
379
+ fail(error instanceof Error ? error.message : String(error));
380
+ }
381
+ }
382
+
356
383
  /** #62 — build for this machine, run it against a real broker, rebuild on save. */
357
384
  async function dev(args: string[]) {
358
385
  const directory = args.find((arg) => !arg.startsWith("--") && !/^\d+$/.test(arg));
@@ -1314,8 +1341,10 @@ switch (command) {
1314
1341
  case "build": {
1315
1342
  const hostBuild = args.includes("--target") && args[args.indexOf("--target") + 1] === "host";
1316
1343
  const directory = args.find((arg) => !arg.startsWith("--") && arg !== "host");
1317
- if (args.includes("--standalone")) await buildStandaloneBinary(directory, hostBuild ? "host" : "linux-x86_64");
1318
- else await build(directory, hostBuild ? "host" : "linux-x86_64");
1344
+ const target = hostBuild ? "host" : "linux-x86_64";
1345
+ if (args.includes("--emit-only")) await emitOnly(directory, target, args.includes("--standalone"));
1346
+ else if (args.includes("--standalone")) await buildStandaloneBinary(directory, target);
1347
+ else await build(directory, target);
1319
1348
  break;
1320
1349
  }
1321
1350
  case "toolchain":
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { chmod, cp, mkdir } from "node:fs/promises";
11
11
  import { resolve } from "node:path";
12
- import { buildArtifact, type BuildInput } from "./build";
12
+ import { buildArtifact, emitGenerated, type BuildInput } from "./build";
13
13
  import type { Bindings } from "./wrap";
14
14
 
15
15
  export type StandaloneBuildInput = BuildInput & {
@@ -37,11 +37,26 @@ export function unsupportedBindings(bindings: Pick<Partial<Bindings>, "services"
37
37
  return reasons;
38
38
  }
39
39
 
40
- export async function buildStandalone(input: StandaloneBuildInput): Promise<StandaloneBuildResult> {
40
+ function assertStandaloneSupported(input: BuildInput): void {
41
41
  const blocked = unsupportedBindings(input.config);
42
42
  if (blocked.length > 0) {
43
43
  throw new Error(`cannot build a standalone binary for this project:\n - ${blocked.join("\n - ")}`);
44
44
  }
45
+ }
46
+
47
+ /**
48
+ * `sproutboat build --standalone --emit-only`: the standalone build up to and
49
+ * including `sprout.generated.js`, byte for byte what a full standalone build
50
+ * compiles, at the same path. Every check that fails a standalone build fails
51
+ * this too; nothing native is acquired or compiled and no binary is copied out.
52
+ */
53
+ export async function emitStandalone(input: BuildInput): Promise<{ generatedPath: string }> {
54
+ assertStandaloneSupported(input);
55
+ return emitGenerated({ ...input, transport: "embedded" });
56
+ }
57
+
58
+ export async function buildStandalone(input: StandaloneBuildInput): Promise<StandaloneBuildResult> {
59
+ assertStandaloneSupported(input);
45
60
  const artifact = await buildArtifact({ ...input, transport: "embedded" });
46
61
 
47
62
  // The sprout *is* the binary: assets and bindings are compiled into it, and
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]",
100
+ args: "[project-dir] [--target host] [--standalone] [--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.",
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. `--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",