sproutboat 0.10.2 → 0.11.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/src/sqlite.ts CHANGED
@@ -13,17 +13,67 @@
13
13
  import { spawn } from "node:child_process";
14
14
  import { createHash } from "node:crypto";
15
15
  import { existsSync } from "node:fs";
16
- import { mkdir, readFile, writeFile } from "node:fs/promises";
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 { resolveHostCompiler } from "./toolchain";
19
20
 
20
21
  export const SQLITE_VERSION = "3.50.4";
21
22
 
22
23
  /** Same cache root Zig uses, so one `rm -rf ~/.cache/sproutboat` clears everything. */
23
- const cacheDir = (): string => resolve(homedir(), ".cache/sproutboat", `sqlite-${SQLITE_VERSION}`);
24
+ const cacheDir = (): string =>
25
+ resolve(
26
+ process.env.SPROUTBOAT_TOOLCHAIN_CACHE ?? resolve(homedir(), ".cache/sproutboat"),
27
+ `sqlite-${SQLITE_VERSION}`,
28
+ );
24
29
  const SQLITE_ZIP = `https://sqlite.org/2025/sqlite-amalgamation-3500400.zip`;
25
30
  const SQLITE_SHA256 = "1d3049dd0f830a025a53105fc79fd2ab9431aea99e137809d064d8ee8356b032";
26
31
 
32
+ const digest = async (path: string): Promise<string> =>
33
+ createHash("sha256")
34
+ .update(await readFile(path))
35
+ .digest("hex");
36
+
37
+ async function withSqliteLock<T>(dir: string, name: string, action: () => Promise<T>): Promise<T> {
38
+ await mkdir(dir, { recursive: true });
39
+ const lock = resolve(dir, `.${name}.lock`);
40
+ const deadline = Date.now() + 30_000;
41
+ for (;;) {
42
+ try {
43
+ await mkdir(lock);
44
+ break;
45
+ } catch {
46
+ if (Date.now() > deadline) throw new Error(`timed out waiting for SQLite cache lock ${lock}`);
47
+ try {
48
+ if (Date.now() - (await stat(lock)).mtimeMs > 5 * 60_000) await rm(lock, { recursive: true, force: true });
49
+ } catch {
50
+ /* publisher released the lock while it was being inspected */
51
+ }
52
+ await Bun.sleep(25);
53
+ }
54
+ }
55
+ try {
56
+ return await action();
57
+ } finally {
58
+ await rm(lock, { recursive: true, force: true });
59
+ }
60
+ }
61
+
62
+ async function validAmalgamation(dir: string): Promise<string | null> {
63
+ const source = resolve(dir, "sqlite3.c");
64
+ try {
65
+ // SAFETY: this manifest is private data written below. The immutable
66
+ // archive identity and the consumed source digest are both verified.
67
+ const manifest = JSON.parse(await readFile(resolve(dir, ".sproutboat-source"), "utf8")) as {
68
+ archiveSha256?: string;
69
+ sourceSha256?: string;
70
+ };
71
+ return manifest.archiveSha256 === SQLITE_SHA256 && manifest.sourceSha256 === (await digest(source)) ? source : null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
27
77
  /**
28
78
  * Compile flags. THREADSAFE=0 because a sprout serves one turn at a time;
29
79
  * DQS=0 rejects double-quoted string literals, which is the setting that turns
@@ -48,23 +98,39 @@ const run = (cmd: string, args: string[]): Promise<{ code: number; stderr: strin
48
98
 
49
99
  async function amalgamation(): Promise<string> {
50
100
  const dir = cacheDir();
51
- const source = resolve(dir, "sqlite3.c");
52
- if (existsSync(source)) return source;
53
- await mkdir(dir, { recursive: true });
101
+ const ready = await validAmalgamation(dir);
102
+ if (ready) return ready;
103
+ return withSqliteLock(dir, "source", async () => (await validAmalgamation(dir)) ?? acquireAmalgamation(dir));
104
+ }
54
105
 
55
- const response = await fetch(SQLITE_ZIP);
56
- if (!response.ok) throw new Error(`could not download SQLite ${SQLITE_VERSION}: HTTP ${response.status}`);
57
- const zip = Buffer.from(await response.arrayBuffer());
58
- const digest = createHash("sha256").update(zip).digest("hex");
59
- if (digest !== SQLITE_SHA256) {
60
- throw new Error(`SQLite amalgamation checksum mismatch: expected ${SQLITE_SHA256}, got ${digest}`);
106
+ async function acquireAmalgamation(dir: string): Promise<string> {
107
+ const source = resolve(dir, "sqlite3.c");
108
+ const manifestPath = resolve(dir, ".sproutboat-source");
109
+ const stage = resolve(dir, `.source-${process.pid}-${crypto.randomUUID()}`);
110
+ try {
111
+ await mkdir(stage);
112
+ const response = await fetch(SQLITE_ZIP);
113
+ if (!response.ok) throw new Error(`could not download SQLite ${SQLITE_VERSION}: HTTP ${response.status}`);
114
+ const zip = Buffer.from(await response.arrayBuffer());
115
+ const archiveDigest = createHash("sha256").update(zip).digest("hex");
116
+ if (archiveDigest !== SQLITE_SHA256) {
117
+ throw new Error(`SQLite amalgamation checksum mismatch: expected ${SQLITE_SHA256}, got ${archiveDigest}`);
118
+ }
119
+ const zipPath = resolve(stage, "amalgamation.zip");
120
+ await writeFile(zipPath, zip);
121
+ const unzip = await run("unzip", ["-oqj", zipPath, "-d", stage]);
122
+ if (unzip.code !== 0) throw new Error(`could not unpack the SQLite amalgamation: ${unzip.stderr}`);
123
+ const stagedSource = resolve(stage, "sqlite3.c");
124
+ if (!existsSync(stagedSource)) throw new Error("the SQLite amalgamation did not contain sqlite3.c");
125
+ const sourceSha256 = await digest(stagedSource);
126
+ const stagedManifest = resolve(stage, ".sproutboat-source");
127
+ await writeFile(stagedManifest, JSON.stringify({ archiveSha256: archiveDigest, sourceSha256 }), { mode: 0o444 });
128
+ await rename(stagedSource, source);
129
+ await rename(stagedManifest, manifestPath);
130
+ return source;
131
+ } finally {
132
+ await rm(stage, { recursive: true, force: true });
61
133
  }
62
- const zipPath = resolve(dir, "amalgamation.zip");
63
- await writeFile(zipPath, zip);
64
- const unzip = await run("unzip", ["-oqj", zipPath, "-d", dir]);
65
- if (unzip.code !== 0) throw new Error(`could not unpack the SQLite amalgamation: ${unzip.stderr}`);
66
- if (!existsSync(source)) throw new Error("the SQLite amalgamation did not contain sqlite3.c");
67
- return source;
68
134
  }
69
135
 
70
136
  export type SqliteObjectInput = {
@@ -78,16 +144,58 @@ export type SqliteObjectInput = {
78
144
  export async function ensureSqliteObject(input: SqliteObjectInput): Promise<string> {
79
145
  const dir = cacheDir();
80
146
  const objectPath = resolve(dir, `sqlite3-${input.target}.o`);
81
- if (existsSync(objectPath)) return objectPath;
82
-
83
147
  const source = await amalgamation();
84
- const [cmd, prefix] =
148
+ const sourceSha256 = await digest(source);
149
+ const manifestPath = `${objectPath}.sproutboat-complete`;
150
+ const flags = ["-O2", ...SQLITE_DEFINES];
151
+ const [cmd, ...prefix] =
85
152
  input.target === "host"
86
- ? (["cc", []] as const)
87
- : ([input.zigBin ?? "zig", ["cc", "-target", "x86_64-linux-musl"]] as const);
88
- const result = await run(cmd, [...prefix, "-c", source, "-o", objectPath, "-O2", ...SQLITE_DEFINES]);
89
- if (result.code !== 0) throw new Error(`could not compile SQLite for ${input.target}:\n${result.stderr}`);
90
- return objectPath;
153
+ ? (await resolveHostCompiler()).cc
154
+ : [input.zigBin ?? "zig", "cc", "-target", "x86_64-linux-musl"];
155
+ const command = [cmd, ...prefix];
156
+ const validCache = async (): Promise<boolean> => {
157
+ try {
158
+ // SAFETY: the manifest is private data written below. Its source, target,
159
+ // flags, and object digest must all agree before the cached object is used.
160
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as {
161
+ sourceSha256?: string;
162
+ target?: string;
163
+ command?: string[];
164
+ flags?: string[];
165
+ objectSha256?: string;
166
+ };
167
+ return (
168
+ manifest.sourceSha256 === sourceSha256 &&
169
+ manifest.target === input.target &&
170
+ JSON.stringify(manifest.command) === JSON.stringify(command) &&
171
+ JSON.stringify(manifest.flags) === JSON.stringify(flags) &&
172
+ manifest.objectSha256 === (await digest(objectPath))
173
+ );
174
+ } catch {
175
+ return false;
176
+ }
177
+ };
178
+ if (await validCache()) return objectPath;
179
+ return withSqliteLock(dir, `object-${input.target}`, async () => {
180
+ if (await validCache()) return objectPath;
181
+ const stage = `${objectPath}.${process.pid}.${crypto.randomUUID()}`;
182
+ try {
183
+ const result = await run(cmd, [...prefix, "-c", source, "-o", stage, ...flags]);
184
+ if (result.code !== 0) throw new Error(`could not compile SQLite for ${input.target}:\n${result.stderr}`);
185
+ const objectSha256 = await digest(stage);
186
+ const stagedManifest = `${manifestPath}.${process.pid}.${crypto.randomUUID()}`;
187
+ await writeFile(
188
+ stagedManifest,
189
+ JSON.stringify({ sourceSha256, target: input.target, command, flags, objectSha256 }),
190
+ { mode: 0o444 },
191
+ );
192
+ await rename(stage, objectPath);
193
+ await rename(stagedManifest, manifestPath);
194
+ return objectPath;
195
+ } finally {
196
+ await rm(stage, { force: true });
197
+ }
198
+ });
91
199
  }
92
200
 
93
201
  /** The SQLite build stamp, for the artifact manifest's provenance string. */
package/src/surface.ts CHANGED
@@ -102,6 +102,15 @@ export const COMMANDS: readonly Command[] = [
102
102
  summary:
103
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.",
104
104
  },
105
+ {
106
+ name: "toolchain",
107
+ group: "Develop",
108
+ emoji: "🩺",
109
+ args: "doctor [--json]",
110
+ brief: "doctor",
111
+ summary:
112
+ "Report the selected compiler inputs, cache locations, overrides, and host prerequisites without downloading or compiling anything.",
113
+ },
105
114
 
106
115
  {
107
116
  name: "deploy",
@@ -207,9 +216,17 @@ export const ENV_VARS: readonly EnvVar[] = [
207
216
  {
208
217
  name: "CC",
209
218
  purpose:
210
- "C compiler used to build uSockets for a host build (default `cc`). A host build needs one regardless: it is what Porffor compiles its own generated C with.",
219
+ "C compiler for a host build (default `cc` if present, else the vendored Zig cross-compiler targeting this host).",
220
+ },
221
+ {
222
+ name: "CXX",
223
+ purpose:
224
+ "C++ compiler for a host build's native-fetch link step (default `c++` if present, else the vendored Zig cross-compiler targeting this host).",
225
+ },
226
+ {
227
+ name: "AR",
228
+ purpose: "Archiver used to assemble uSockets.a for a host build (default `ar` if present, else Zig's).",
211
229
  },
212
- { name: "AR", purpose: "Archiver used to assemble uSockets.a for a host build (default `ar`)." },
213
230
  { name: "SPROUTBOAT_COMPILE_TIMEOUT_MS", purpose: "Porffor compile timeout in ms (default 600000)." },
214
231
  {
215
232
  name: "SPROUTBOAT_TOOLCHAIN_CACHE",