sproutboat 0.11.11 → 0.11.13

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
@@ -8,6 +8,38 @@ a breaking change.
8
8
  Reconstructed from git history on 2026-09-03 for everything through v0.4.11;
9
9
  maintained going forward by the `release` skill.
10
10
 
11
+ ## Unreleased
12
+
13
+ ## [0.11.13] - 2026-09-26
14
+
15
+ ### Added
16
+ - Record compile duration in the artifact manifest so the platform can show it per deployment.
17
+ - Support selected `node:path`, `node:url`, and `node:querystring` APIs in handlers.
18
+
19
+ ### Changed
20
+ - Reuse checksum-verified native binaries for identical release builds and
21
+ refresh broker asset sidecars without recompiling the handler.
22
+ - Report native compilation time and binary cache status after `build`.
23
+ - Reject unsupported service bindings before starting a standalone build.
24
+ - Fetch the pinned Mozilla CA bundle from curl's dated archive so new upstream
25
+ revisions do not break fresh standalone builds.
26
+ - Update the pinned toolchain to `0.4.13` and the artifact contract to `0.3.0`.
27
+
28
+ ## [0.11.12] - 2026-09-24
29
+
30
+ ### Changed
31
+ - Porffor alpha 9 handles ESM modules without the external esbuild executable.
32
+ The CLI now requires `@sproutboat/toolchain@^0.4.12`, runtime `^0.12.1`,
33
+ and wire `^0.9.1`.
34
+ Removed the CLI's esbuild dependency, platform binary copies, and compile-time
35
+ PATH lookup. Handler validation and shimming still use Bun's built-in bundler.
36
+ The required `esbuildVersion` manifest field remains for older artifact
37
+ readers and now records `unused`.
38
+ - Local `dev` rebuilds keep a stable generated entry path for each process, so
39
+ Porffor alpha 9 can reuse unchanged compiled C units. In a local `hello`
40
+ handler-edit comparison, compilation took 549 ms with the stable path versus
41
+ 1,505 ms with changing paths. Candidate binaries remain isolated.
42
+
11
43
  ## [0.11.11] - 2026-09-24
12
44
  ### Fixed
13
45
  - Updated the native runtime to `0.12.0` so repeated HMAC signing uses the raw
@@ -15,7 +47,6 @@ maintained going forward by the `release` skill.
15
47
  - Updated the toolchain to `0.4.11` so `Uint8Array.from({ length: 16 }, mapFn)`
16
48
  produces the intended key bytes in standalone native builds.
17
49
  - Added the two-round HMAC vector to broker and standalone conformance checks.
18
-
19
50
  ## [0.11.10] - 2026-09-18
20
51
  ### Changed
21
52
  - Bumped `@sproutboat/toolchain` to `0.4.10`, pinning Porffor to the
@@ -596,7 +627,9 @@ its own package.
596
627
  - Renamed the package to `sproutboat` (was `@sproutboat/cli`); dropped the
597
628
  `sprout` bin alias in favour of a user-defined shell alias.
598
629
 
599
- [Unreleased]: https://github.com/baronunread/sproutboat-cli/compare/v0.11.11...HEAD
630
+ [Unreleased]: https://github.com/baronunread/sproutboat-cli/compare/v0.11.13...HEAD
631
+ [0.11.13]: https://github.com/baronunread/sproutboat-cli/compare/v0.11.12...v0.11.13
632
+ [0.11.12]: https://github.com/baronunread/sproutboat-cli/compare/v0.11.11...v0.11.12
600
633
  [0.11.11]: https://github.com/baronunread/sproutboat-cli/compare/v0.11.10...v0.11.11
601
634
  [0.11.10]: https://github.com/baronunread/sproutboat-cli/compare/v0.11.9...v0.11.10
602
635
  [0.11.9]: https://github.com/baronunread/sproutboat-cli/compare/v0.11.8...v0.11.9
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.11.11 · runs on Bun (use `bunx`, not `npx`)
6
+ **Package:** `sproutboat` 0.11.13 · runs on Bun (use `bunx`, not `npx`)
7
7
 
8
8
  ## Commands
9
9
 
@@ -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/8f01541+uws/360c276d` |
79
+ | Provenance stamp | `zig-musl/0.16.0+porffor/de4eb58+uws/360c276d` |
80
80
  | Artifact schema | `2` |
81
81
  | Runtime | `native-fetch` |
82
82
  | Capability profile | `http-sync-v0` |
@@ -6,10 +6,9 @@ to its upstream source.
6
6
 
7
7
  | Component | Version or revision | Distribution role | License | Source |
8
8
  | --- | --- | --- | --- | --- |
9
- | Porffor | `a415d194e74948f0ac32b9d608153ea8720c71fd` | Acquired on first application build | MIT | https://github.com/CanadaHonk/porffor |
9
+ | Porffor | `de4eb588264885b3a1596f75010e371a2052033f` | Acquired on first application build | MIT | https://github.com/CanadaHonk/porffor |
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
- | esbuild | `0.28.2` | Embedded platform executable | MIT | https://esbuild.github.io |
13
12
  | SQLite | `3.50.4` | Acquired for standalone builds | Public domain | https://sqlite.org |
14
13
  | BearSSL | `0.6` | Acquired for standalone builds | MIT | https://bearssl.org |
15
14
 
@@ -17,7 +16,7 @@ to its upstream source.
17
16
 
18
17
  | Component | SHA-256 |
19
18
  | --- | --- |
20
- | Porffor source archive | `677779c8be49efc28686378f665cd6fc4a8a2dd81e7b15a89298103ded960829` |
19
+ | Porffor source archive | `1a62187a93356b36cb6ac703ce9770c8917e4fa65545fd7c33ee8a180ae5a4c9` |
21
20
  | Embedded uWebSockets archive | `e83736f3f8cf9d56a1ebe6ea61625a7af12386763374d47c14cff472ada7484a` |
22
21
  | SQLite amalgamation archive | `1d3049dd0f830a025a53105fc79fd2ab9431aea99e137809d064d8ee8356b032` |
23
22
  | BearSSL source archive | `6705bba1714961b41a728dfc5debbe348d2966c117649392f8c8139efc83ff14` |
@@ -25,5 +24,4 @@ to its upstream source.
25
24
 
26
25
  The complete Apache-2.0 notice for uWebSockets and uSockets is retained in the
27
26
  embedded source archive. Porffor and BearSSL retain their MIT notices in the
28
- source archives acquired at the revisions above. esbuild's MIT notice is
29
- available from its upstream distribution.
27
+ source archives acquired at the revisions above.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sproutboat",
3
- "version": "0.11.11",
3
+ "version": "0.11.13",
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",
@@ -61,13 +61,12 @@
61
61
  "kitchen-sink:standalone": "bun examples/kitchen-sink/harness-standalone.ts"
62
62
  },
63
63
  "dependencies": {
64
- "@sproutboat/artifact": "^0.2.0",
64
+ "@sproutboat/artifact": "^0.3.0",
65
65
  "@sproutboat/assets": "^0.3.0",
66
66
  "@sproutboat/config": "^0.4.0",
67
- "@sproutboat/runtime": "^0.12.0",
68
- "@sproutboat/toolchain": "^0.4.11",
69
- "@sproutboat/wire": "^0.9.0",
70
- "esbuild": "^0.28.2"
67
+ "@sproutboat/runtime": "^0.12.1",
68
+ "@sproutboat/toolchain": "^0.4.13",
69
+ "@sproutboat/wire": "^0.9.1"
71
70
  },
72
71
  "devDependencies": {
73
72
  "@oxlint/plugins": "1.81.0",
@@ -78,10 +77,10 @@
78
77
  "typescript": "7.0.2"
79
78
  },
80
79
  "optionalDependencies": {
81
- "@sproutboat/cli-darwin-arm64": "0.11.11",
82
- "@sproutboat/cli-darwin-x64": "0.11.11",
83
- "@sproutboat/cli-linux-arm64": "0.11.11",
84
- "@sproutboat/cli-linux-x64": "0.11.11"
80
+ "@sproutboat/cli-darwin-arm64": "0.11.13",
81
+ "@sproutboat/cli-darwin-x64": "0.11.13",
82
+ "@sproutboat/cli-linux-arm64": "0.11.13",
83
+ "@sproutboat/cli-linux-x64": "0.11.13"
85
84
  },
86
85
  "engines": {
87
86
  "node": ">=18"
@@ -15,7 +15,7 @@
15
15
  * GitHub's only x64 macOS image (`macos-13`) routinely sits queued for half an
16
16
  * hour while every other leg finishes in one minute.
17
17
  */
18
- import { chmod, copyFile, mkdir } from "node:fs/promises";
18
+ import { chmod, copyFile, mkdir, rm } from "node:fs/promises";
19
19
  import { resolve } from "node:path";
20
20
  import { ensureZigArchive, type ZigPlatform } from "../src/toolchain";
21
21
 
@@ -39,49 +39,11 @@ function hostTarget(): Target {
39
39
 
40
40
  const root = resolve(import.meta.dir, "..");
41
41
 
42
- /** esbuild ships one binary per platform (`@esbuild/<os>-<arch>`); a cross
43
- * target's copy is never installed locally (npm/bun refuse it — the package
44
- * declares `os`/`cpu` and gets skipped for any other host), so fetch that
45
- * platform's tarball from the registry instead, same shape as ensureZig's
46
- * prebuilt-archive fetches. */
47
- async function esbuildBinaryFor(target: Target): Promise<string> {
48
- const host = hostTarget();
49
- if (target.platform === host.platform && target.arch === host.arch) {
50
- try {
51
- return Bun.resolveSync(`@esbuild/${target.platform}-${target.arch}/bin/esbuild`, import.meta.dir);
52
- } catch {
53
- throw new Error("host esbuild binary is missing; run bun install first");
54
- }
55
- }
56
- // SAFETY: package.json is this repo's own manifest, with a required esbuild dependency entry.
57
- const pkg = (await Bun.file(resolve(root, "package.json")).json()) as { dependencies: { esbuild: string } };
58
- const version = pkg.dependencies.esbuild.replace(/^[\^~]/, "");
59
- const name = `${target.platform}-${target.arch}`;
60
- const cacheDir = resolve(
61
- process.env.SPROUTBOAT_TOOLCHAIN_CACHE ?? `${process.env.HOME}/.cache/sproutboat`,
62
- `esbuild-${version}-${name}`,
63
- );
64
- const cached = resolve(cacheDir, "esbuild");
65
- if (await Bun.file(cached).exists()) return cached;
66
- const res = await fetch(`https://registry.npmjs.org/@esbuild/${name}/-/${name}-${version}.tgz`);
67
- if (!res.ok) throw new Error(`could not fetch @esbuild/${name}@${version}: HTTP ${res.status}`);
68
- const tarPath = resolve(cacheDir, "esbuild.tgz");
69
- await mkdir(cacheDir, { recursive: true });
70
- await Bun.write(tarPath, await res.arrayBuffer());
71
- const untar = Bun.spawn(["tar", "-xzf", tarPath, "-C", cacheDir, "--strip-components=2", "package/bin/esbuild"], {
72
- stdout: "pipe",
73
- stderr: "pipe",
74
- });
75
- const [code, err] = await Promise.all([untar.exited, new Response(untar.stderr).text()]);
76
- if (code !== 0) throw new Error(`could not extract @esbuild/${name}: ${err.trim()}`);
77
- await chmod(cached, 0o755);
78
- return cached;
79
- }
80
-
81
42
  async function buildOne(target: Target): Promise<void> {
82
43
  const packageDir = resolve(root, "platform-packages", `${target.platform}-${target.arch}`);
83
44
  const out = resolve(packageDir, "bin", "sproutboat");
84
45
  await mkdir(resolve(out, ".."), { recursive: true });
46
+ await rm(resolve(packageDir, "bin", "esbuild"), { force: true });
85
47
  // SAFETY: package.json is this release's manifest and npm requires its version.
86
48
  const version = ((await Bun.file(resolve(root, "package.json")).json()) as { version: string }).version;
87
49
  // SAFETY: every checked-in platform package manifest owns a required string version.
@@ -103,9 +65,6 @@ async function buildOne(target: Target): Promise<void> {
103
65
  { cwd: root, stdout: "inherit", stderr: "inherit" },
104
66
  );
105
67
  if ((await child.exited) !== 0) throw new Error(`compile failed for ${target.platform}-${target.arch}`);
106
- const esbuild = await esbuildBinaryFor(target);
107
- const packagedEsbuild = resolve(packageDir, "bin", "esbuild");
108
- await copyFile(esbuild, packagedEsbuild);
109
68
  // The compressed archive (~50 MB), not the ~400 MB extracted install: Zig's
110
69
  // lib/ carries libc/libc++ sources for every target it can cross-compile to,
111
70
  // which only matters once, on extract. `ensureZig` (src/toolchain.ts) looks
@@ -114,7 +73,6 @@ async function buildOne(target: Target): Promise<void> {
114
73
  // same shape as the vendored uWebSockets archive.
115
74
  await copyFile(await ensureZigArchive(target.zigPlatform), resolve(packageDir, "bin", "zig.tar.xz"));
116
75
  await copyFile(resolve(root, "THIRD_PARTY_NOTICES.md"), resolve(packageDir, "THIRD_PARTY_NOTICES.md"));
117
- await chmod(packagedEsbuild, 0o755);
118
76
  await chmod(out, 0o755);
119
77
  }
120
78
 
package/src/bearssl.ts CHANGED
@@ -34,7 +34,8 @@ const BEARSSL_SHA256 = "6705bba1714961b41a728dfc5debbe348d2966c117649392f8c8139e
34
34
  * release is unknown until the next one. Fine while releases are frequent;
35
35
  * revisit if a user is ever stuck on an old CLI for months.
36
36
  */
37
- const CACERT_URL = "https://curl.se/ca/cacert.pem";
37
+ // Use the dated archive so its bytes stay equal to the pinned digest.
38
+ const CACERT_URL = "https://curl.se/ca/cacert-2026-08-13.pem";
38
39
  const CACERT_SHA256 = "f66dff1bdf8f96060b8177976f8b7d9254bc89bc4db933d769f7384d28480bc9";
39
40
 
40
41
  const cacheDir = (): string =>
package/src/build.ts CHANGED
@@ -1,12 +1,13 @@
1
- import { createHash } from "node:crypto";
2
- import { cp, mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
- import { resolve } from "node:path";
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import { cp, mkdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
+ import { dirname, resolve } from "node:path";
4
4
  import { walkAssets, type AssetManifest } from "./assets";
5
5
  import { resourceRefs, type SproutboatConfig } from "./config";
6
6
  import { ensureSqliteObject } from "./sqlite";
7
7
  import { sqliteStamp } from "./sqlite";
8
8
  import { bearsslStamp, ensureBearssl } from "./bearssl";
9
- import { compileSprout, type Transport } from "./compile";
9
+ import { compileSprout, loadPrelude, type Transport } from "./compile";
10
+ import { compileCached } from "./compile-cache";
10
11
  import {
11
12
  ARTIFACT_SCHEMA_VERSION,
12
13
  CAPABILITY_PROFILE,
@@ -16,6 +17,10 @@ import {
16
17
  type ArtifactManifest,
17
18
  } from "./manifest";
18
19
  import { ensureZig, esbuildVersion, porfforVersion, toolchainStamp } from "./toolchain";
20
+ import { PORFFOR_ARCHIVE_SHA256, PORFFOR_COMMIT_FULL } from "./porffor-toolchain";
21
+ import { version as toolchainPackageVersion } from "@sproutboat/toolchain/package.json" with { type: "json" };
22
+ import { version as cliVersion } from "../package.json" with { type: "json" };
23
+ import { wrapNativeFetchHandler } from "./wrap";
19
24
 
20
25
  export type BuildInput = {
21
26
  projectDir: string;
@@ -47,11 +52,15 @@ export type BuildInput = {
47
52
  reuseSproutPath?: string;
48
53
  /** An isolated artifact directory, used by dev candidates to avoid mixing snapshots. */
49
54
  outputDirectory?: string;
55
+ /** Stable compiler input used only by the local dev loop. */
56
+ generatedPath?: string;
50
57
  };
51
58
 
52
59
  export type BuildOutput = {
53
60
  artifactDir: string;
54
61
  manifest: ArtifactManifest;
62
+ compileCache: "hit" | "miss" | "bypass";
63
+ compileMs: number;
55
64
  };
56
65
 
57
66
  /** Baking megabytes of assets into the module makes the Porffor compile crawl;
@@ -62,6 +71,39 @@ function digest(value: Uint8Array | string): `sha256:${string}` {
62
71
  return `sha256:${createHash("sha256").update(value).digest("hex")}`;
63
72
  }
64
73
 
74
+ async function integrationIdentity(): Promise<string> {
75
+ // A published CLI has immutable bundled integration code and a release
76
+ // version. Source checkouts also hash their live files, so local edits never
77
+ // accidentally reuse a binary compiled by an earlier checkout state.
78
+ if (import.meta.url.includes("/$bunfs/")) {
79
+ const executable = await stat(process.execPath);
80
+ return `release:${cliVersion}:${executable.size}:${executable.mtimeMs}`;
81
+ }
82
+ const files = ["build.ts", "compile.ts", "sqlite.ts", "bearssl.ts", "toolchain.ts"].map((file) =>
83
+ resolve(import.meta.dir, file),
84
+ );
85
+ files.push(Bun.resolveSync("@sproutboat/toolchain/patch", import.meta.dir));
86
+ return digest(Buffer.concat(await Promise.all(files.map((file) => readFile(file)))));
87
+ }
88
+
89
+ async function acquireArtifactLock(artifactDir: string): Promise<string> {
90
+ const lock = `${artifactDir}.lock`;
91
+ await mkdir(dirname(lock), { recursive: true });
92
+ const deadline = Date.now() + 12 * 60_000;
93
+ while (true) {
94
+ try {
95
+ await mkdir(lock);
96
+ return lock;
97
+ } catch (error) {
98
+ if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) throw error;
99
+ if (Date.now() > deadline) throw new Error(`timed out waiting for artifact lock ${lock}`);
100
+ const info = await stat(lock).catch(() => null);
101
+ if (info && Date.now() - info.mtimeMs > 15 * 60_000) await rm(lock, { recursive: true, force: true });
102
+ else await Bun.sleep(100);
103
+ }
104
+ }
105
+ }
106
+
65
107
  /**
66
108
  * Cross-compile a handler into a linux-x86_64 native-fetch server binary with
67
109
  * Porffor + Zig — no Docker. The binary is not run here (the build host may not
@@ -73,30 +115,6 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
73
115
  const sourceHash = digest(source);
74
116
  const target = input.target ?? "linux-x86_64";
75
117
  const embedded = input.transport === "embedded";
76
- // The directory identity must change whenever an input that changes the
77
- // generated C or link line changes. A source digest alone reused an artifact
78
- // path across compiler pins, targets, and standalone ABI settings.
79
- const artifactId = digest(
80
- JSON.stringify({
81
- sourceHash,
82
- target,
83
- transport: input.transport ?? "broker",
84
- optimize: input.optimize ?? "release",
85
- compatibilityDate: input.config.compatibility_date,
86
- config: input.config,
87
- toolchain: toolchainStamp(),
88
- compiler:
89
- target === "host"
90
- ? `host:${process.env.CC ?? "cc"}`
91
- : process.env.SPROUTBOAT_ZIG
92
- ? `override:${process.env.SPROUTBOAT_ZIG}`
93
- : "managed-zig",
94
- native: embedded ? [sqliteStamp(), bearsslStamp()] : [],
95
- }),
96
- ).slice("sha256:".length, 24);
97
- const artifactDir = input.outputDirectory ?? resolve(input.projectDir, ".sproutboat/dist", artifactId);
98
- const sproutPath = resolve(artifactDir, "sprout");
99
- await mkdir(artifactDir, { recursive: true });
100
118
 
101
119
  // #74 — split each storage-binding array into its binding-name list (the
102
120
  // legacy shape the prelude/broker read) plus a `resources` map { binding ->
@@ -132,33 +150,34 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
132
150
  resources,
133
151
  };
134
152
 
135
- // A host build never shells out to `zig`, so do not fetch a 50 MB toolchain
136
- // for it — that download is the slowest part of a first local build.
137
- const host = input.target === "host";
138
- const zigBin = host ? undefined : await ensureZig();
139
- // #15 — an embedded sprout carries its own storage and TLS instead of talking
140
- // to a broker: SQLite and BearSSL are compiled once per target and added to
141
- // the link line, and BearSSL's header to the compile line.
142
- const sqliteObject = embedded ? await ensureSqliteObject({ target, zigBin }) : null;
143
- const tls = embedded ? await ensureBearssl({ target, zigBin }) : null;
144
- const extraLink = [...(sqliteObject ? [sqliteObject] : []), ...(tls ? tls.objects : [])];
145
- const extraCflags = tls ? ["-I", tls.includeDir] : [];
153
+ const host = target === "host";
154
+ const assetDir = input.config.assets ? resolve(input.projectDir, input.config.assets.directory) : undefined;
155
+ if (
156
+ assetDir &&
157
+ !(await stat(assetDir)
158
+ .then((s) => s.isDirectory())
159
+ .catch(() => false))
160
+ ) {
161
+ throw new Error(`assets.directory "${input.config.assets?.directory}" not found — run your site build first`);
162
+ }
163
+ const assetManifest: AssetManifest | undefined =
164
+ assetDir && input.config.assets
165
+ ? {
166
+ notFound: input.config.assets.not_found_handling ?? "none",
167
+ runSproutFirst: input.config.assets.run_sprout_first ?? false,
168
+ files: walkAssets(assetDir),
169
+ }
170
+ : undefined;
146
171
  // #15 — an embedded binary has no files beside it, so assets are baked into
147
172
  // the module. Read them from the source directory: the artifact copy happens
148
173
  // after the compile, and the compile is what needs them. Bytes travel as a
149
174
  // latin1 string, one char per byte, which is what the asset shim hands back.
150
175
  let bakedAssets: { manifest: AssetManifest; files: Record<string, string> } | undefined;
151
- if (input.transport === "embedded" && input.config.assets) {
152
- const dir = resolve(input.projectDir, input.config.assets.directory);
153
- const manifest: AssetManifest = {
154
- notFound: input.config.assets.not_found_handling ?? "none",
155
- runSproutFirst: input.config.assets.run_sprout_first ?? false,
156
- files: walkAssets(dir),
157
- };
176
+ if (embedded && assetDir && assetManifest) {
158
177
  const files: Record<string, string> = {};
159
178
  let total = 0;
160
- for (const key of Object.keys(manifest.files)) {
161
- const bytes = await readFile(resolve(dir, `.${key}`));
179
+ for (const key of Object.keys(assetManifest.files)) {
180
+ const bytes = await readFile(resolve(assetDir, `.${key}`));
162
181
  total += bytes.byteLength;
163
182
  if (total > MAX_BAKED_ASSET_BYTES) {
164
183
  throw new Error(
@@ -168,85 +187,156 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
168
187
  }
169
188
  files[key] = bytes.toString("latin1");
170
189
  }
171
- bakedAssets = { manifest, files };
190
+ bakedAssets = { manifest: assetManifest, files };
172
191
  }
173
192
 
174
- // Hoisted above the compile: #126 bakes it into the binary, and the manifest
175
- // records the same instant rather than a slightly later one.
193
+ // Version metadata is compiled into the executable. Its timestamp makes a
194
+ // new binary necessary on every build when this binding is configured.
176
195
  const builtAt = new Date().toISOString();
196
+ const versionId = digest(
197
+ JSON.stringify({ sourceHash, target, config: input.config, assets: assetManifest, toolchain: toolchainStamp() }),
198
+ ).slice("sha256:".length, 24);
177
199
  const versionMetadata = input.config.version_metadata
178
- ? { binding: input.config.version_metadata, id: artifactId, tag: input.config.name, timestamp: builtAt }
200
+ ? { binding: input.config.version_metadata, id: versionId, tag: input.config.name, timestamp: builtAt }
179
201
  : undefined;
202
+ const generatedSource = wrapNativeFetchHandler(
203
+ source.toString(),
204
+ await loadPrelude(input.transport ?? "broker"),
205
+ input.config.vars ?? {},
206
+ bindings,
207
+ undefined,
208
+ input.config.compatibility_date,
209
+ input.config.name,
210
+ bakedAssets,
211
+ input.transport,
212
+ versionMetadata,
213
+ );
214
+ const compileKey = digest(
215
+ JSON.stringify({
216
+ generatedSource,
217
+ target,
218
+ optimize: input.optimize ?? "release",
219
+ toolchain: toolchainStamp(),
220
+ toolchainPackageVersion,
221
+ porffor: [PORFFOR_COMMIT_FULL, PORFFOR_ARCHIVE_SHA256],
222
+ native: embedded ? [sqliteStamp(), bearsslStamp()] : [],
223
+ integration: await integrationIdentity(),
224
+ }),
225
+ ).slice("sha256:".length);
226
+ const artifactId = versionMetadata
227
+ ? versionId
228
+ : digest(JSON.stringify({ compileKey, config: input.config, assets: assetManifest })).slice("sha256:".length, 24);
229
+ const artifactDir = input.outputDirectory ?? resolve(input.projectDir, ".sproutboat/dist", artifactId);
230
+ const sproutPath = resolve(artifactDir, "sprout");
231
+ const artifactLock = await acquireArtifactLock(artifactDir);
232
+ try {
233
+ await mkdir(artifactDir, { recursive: true });
180
234
 
181
- if (input.reuseSproutPath) await cp(input.reuseSproutPath, sproutPath);
182
- else
183
- await compileSprout({
184
- sourcePath: input.sourcePath,
185
- source: input.source,
186
- outPath: sproutPath,
187
- vars: input.config.vars ?? {},
188
- bindings,
189
- zigBin,
190
- target: input.target,
191
- compatibilityDate: input.config.compatibility_date,
192
- transport: input.transport,
193
- appName: input.config.name,
194
- assets: bakedAssets,
195
- extraLink,
196
- extraCflags,
197
- optimize: input.optimize,
198
- versionMetadata,
199
- });
200
-
201
- const sprout = await readFile(sproutPath);
202
- const manifest: ArtifactManifest = {
203
- schemaVersion: ARTIFACT_SCHEMA_VERSION,
204
- project: input.config.name,
205
- target: host ? hostTarget() : DEPLOY_TARGET,
206
- runtime: RUNTIME,
207
- capabilityProfile: CAPABILITY_PROFILE,
208
- porfforVersion: porfforVersion(),
209
- esbuildVersion: esbuildVersion(),
210
- buildImage: toolchainStamp(),
211
- compatibilityDate: input.config.compatibility_date,
212
- sourceHash,
213
- binaryHash: digest(sprout),
214
- binarySize: (await stat(sproutPath)).size,
215
- builtAt,
216
- };
217
- await writeFile(resolve(artifactDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
218
- // Bindings live beside the manifest, not in it: the artifact manifest schema is
219
- // frozen at v2. The control plane reads this to configure the per-deployment
220
- // broker (KV / D1 / R2 / queue names, secret names, outbound allowlist, cron
221
- // schedules, Durable Object classes).
222
- const hasBindings =
223
- Object.values(bindings).some((value) => Array.isArray(value) && value.length > 0) ||
224
- Object.keys(bindings.resources).length > 0 ||
225
- Object.keys(bindings.vars).length > 0;
226
- if (hasBindings) {
227
- await writeFile(resolve(artifactDir, "bindings.json"), `${JSON.stringify(bindings, null, 2)}\n`);
228
- }
229
-
230
- // Static assets: copy the directory next to the artifact and record a manifest
231
- // the edge serves from directly (assets-first) and the broker reads for
232
- // `env.<ASSETS>.fetch()`.
233
- if (input.config.assets) {
234
- const srcDir = resolve(input.projectDir, input.config.assets.directory);
235
- const outDir = resolve(artifactDir, "assets");
235
+ const compile = async (outPath: string) => {
236
+ // Acquire native inputs only after a cache miss. A host build never needs
237
+ // the cross-compiler; embedded builds link SQLite and BearSSL as well.
238
+ const zigBin = host ? undefined : await ensureZig();
239
+ const [sqliteObject, tls] = embedded
240
+ ? await Promise.all([ensureSqliteObject({ target, zigBin }), ensureBearssl({ target, zigBin })])
241
+ : [null, null];
242
+ await compileSprout({
243
+ sourcePath: input.sourcePath,
244
+ generatedPath: input.generatedPath,
245
+ generatedSource,
246
+ outPath,
247
+ vars: input.config.vars ?? {},
248
+ bindings,
249
+ zigBin,
250
+ target: input.target,
251
+ compatibilityDate: input.config.compatibility_date,
252
+ transport: input.transport,
253
+ appName: input.config.name,
254
+ assets: bakedAssets,
255
+ extraLink: [...(sqliteObject ? [sqliteObject] : []), ...(tls ? tls.objects : [])],
256
+ extraCflags: tls ? ["-I", tls.includeDir] : [],
257
+ optimize: input.optimize,
258
+ versionMetadata,
259
+ });
260
+ };
261
+ let compileCache: BuildOutput["compileCache"] = "bypass";
262
+ const compileStartedAt = performance.now();
236
263
  if (
237
- !(await stat(srcDir)
238
- .then((s) => s.isDirectory())
239
- .catch(() => false))
264
+ !input.reuseSproutPath &&
265
+ !host &&
266
+ input.optimize !== "dev" &&
267
+ !input.outputDirectory &&
268
+ !versionMetadata &&
269
+ !process.env.SPROUTBOAT_PORFFOR_DIR &&
270
+ !process.env.SPROUTBOAT_ZIG &&
271
+ !process.env.SPROUTBOAT_UWS_TARBALL &&
272
+ !process.env.SPROUTBOAT_BUILD_UWS_FROM_SOURCE &&
273
+ !process.env.PORFFOR_VERSION
240
274
  ) {
241
- throw new Error(`assets.directory "${input.config.assets.directory}" not found — run your site build first`);
275
+ compileCache = await compileCached(
276
+ resolve(input.projectDir, ".sproutboat/compile-cache"),
277
+ compileKey,
278
+ sproutPath,
279
+ compile,
280
+ );
281
+ } else {
282
+ const candidate = resolve(artifactDir, `.sprout-${randomUUID()}`);
283
+ try {
284
+ if (input.reuseSproutPath) await cp(input.reuseSproutPath, candidate);
285
+ else await compile(candidate);
286
+ await rename(candidate, sproutPath);
287
+ } finally {
288
+ await rm(candidate, { force: true });
289
+ }
242
290
  }
243
- await cp(srcDir, outDir, { recursive: true });
244
- const assetManifest: AssetManifest = {
245
- notFound: input.config.assets.not_found_handling ?? "none",
246
- runSproutFirst: input.config.assets.run_sprout_first ?? false,
247
- files: walkAssets(outDir),
291
+ const compileMs = Math.round(performance.now() - compileStartedAt);
292
+
293
+ const sprout = await readFile(sproutPath);
294
+ const manifest: ArtifactManifest & { compileMs: number } = {
295
+ schemaVersion: ARTIFACT_SCHEMA_VERSION,
296
+ project: input.config.name,
297
+ target: host ? hostTarget() : DEPLOY_TARGET,
298
+ runtime: RUNTIME,
299
+ capabilityProfile: CAPABILITY_PROFILE,
300
+ porfforVersion: porfforVersion(),
301
+ esbuildVersion: esbuildVersion(),
302
+ buildImage: toolchainStamp(),
303
+ compatibilityDate: input.config.compatibility_date,
304
+ sourceHash,
305
+ binaryHash: digest(sprout),
306
+ binarySize: (await stat(sproutPath)).size,
307
+ compileMs,
308
+ builtAt,
248
309
  };
249
- await writeFile(resolve(artifactDir, "assets.json"), `${JSON.stringify(assetManifest, null, 2)}\n`);
310
+ await writeFile(resolve(artifactDir, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`);
311
+ // Bindings live beside the manifest, not in it: the artifact manifest schema is
312
+ // frozen at v2. The control plane reads this to configure the per-deployment
313
+ // broker (KV / D1 / R2 / queue names, secret names, outbound allowlist, cron
314
+ // schedules, Durable Object classes).
315
+ const hasBindings =
316
+ Object.values(bindings).some((value) => Array.isArray(value) && value.length > 0) ||
317
+ Object.keys(bindings.resources).length > 0 ||
318
+ Object.keys(bindings.vars).length > 0;
319
+ if (hasBindings) {
320
+ await writeFile(resolve(artifactDir, "bindings.json"), `${JSON.stringify(bindings, null, 2)}\n`);
321
+ } else await rm(resolve(artifactDir, "bindings.json"), { force: true });
322
+
323
+ // Static assets: copy the directory next to the artifact and record a manifest
324
+ // the edge serves from directly (assets-first) and the broker reads for
325
+ // `env.<ASSETS>.fetch()`.
326
+ if (assetDir && assetManifest) {
327
+ const outDir = resolve(artifactDir, "assets");
328
+ await rm(outDir, { recursive: true, force: true });
329
+ await cp(assetDir, outDir, { recursive: true });
330
+ if (JSON.stringify(walkAssets(outDir)) !== JSON.stringify(assetManifest.files)) {
331
+ throw new Error("assets changed during the build; retry after the asset build finishes");
332
+ }
333
+ await writeFile(resolve(artifactDir, "assets.json"), `${JSON.stringify(assetManifest, null, 2)}\n`);
334
+ } else {
335
+ await rm(resolve(artifactDir, "assets.json"), { force: true });
336
+ await rm(resolve(artifactDir, "assets"), { recursive: true, force: true });
337
+ }
338
+ return { artifactDir, manifest, compileCache, compileMs };
339
+ } finally {
340
+ await rm(artifactLock, { recursive: true, force: true });
250
341
  }
251
- return { artifactDir, manifest };
252
342
  }