sproutboat 0.10.3 → 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/CHANGELOG.md +51 -1
- package/README.md +7 -0
- package/SURFACE.md +6 -4
- package/THIRD_PARTY_NOTICES.md +29 -0
- package/bin/sproutboat.cjs +17 -19
- package/package.json +10 -3
- package/scripts/build-cli.ts +113 -40
- package/src/bearssl.ts +203 -50
- package/src/build.ts +25 -4
- package/src/compile.ts +31 -11
- package/src/main.ts +40 -0
- package/src/sqlite.ts +133 -25
- package/src/surface.ts +19 -2
- package/src/toolchain.ts +314 -61
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 =>
|
|
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
|
|
52
|
-
if (
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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
|
-
? (
|
|
87
|
-
:
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
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",
|