sproutboat 0.7.0 → 0.9.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 +304 -0
- package/README.md +33 -1
- package/SURFACE.md +15 -5
- package/package.json +12 -6
- package/src/bearssl.ts +180 -0
- package/src/broker.ts +420 -19
- package/src/build.ts +61 -1
- package/src/compile.ts +94 -14
- package/src/config.ts +25 -0
- package/src/dev.ts +2 -0
- package/src/main.ts +260 -6
- package/src/native-fetch-prelude.js +130 -171
- package/src/patch-porffor.ts +104 -6
- package/src/sqlite.ts +99 -0
- package/src/standalone-build.ts +64 -0
- package/src/surface.ts +54 -5
- package/src/toolchain.ts +71 -0
- package/src/transport-broker.js +299 -0
- package/src/transport-embedded.js +1571 -0
- package/src/types-gen.ts +90 -0
- package/src/wrap.ts +46 -2
- package/types/sproutboat.d.ts +217 -0
- package/vendor/sqlite-amalgamation-3500400/shell.c +34019 -0
- package/vendor/sqlite-amalgamation-3500400/sqlite3.c +262899 -0
- package/vendor/sqlite-amalgamation-3500400/sqlite3.h +13775 -0
- package/vendor/sqlite-amalgamation-3500400/sqlite3ext.h +723 -0
- package/vendor/sqlite-amalgamation.zip +0 -0
package/src/build.ts
CHANGED
|
@@ -3,7 +3,9 @@ import { cp, mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { walkAssets, type AssetManifest } from "./assets";
|
|
5
5
|
import { resourceRefs, type SproutboatConfig } from "./config";
|
|
6
|
-
import {
|
|
6
|
+
import { ensureSqliteObject } from "./sqlite";
|
|
7
|
+
import { ensureBearssl } from "./bearssl";
|
|
8
|
+
import { compileSprout, type Transport } from "./compile";
|
|
7
9
|
import {
|
|
8
10
|
ARTIFACT_SCHEMA_VERSION,
|
|
9
11
|
CAPABILITY_PROFILE,
|
|
@@ -30,6 +32,16 @@ export type BuildInput = {
|
|
|
30
32
|
* the real target, which is what stops the result being deployed.
|
|
31
33
|
*/
|
|
32
34
|
target?: "linux-x86_64" | "host";
|
|
35
|
+
/** #15 — `embedded` compiles SQLite into the sprout instead of a broker
|
|
36
|
+
* transport. Defaults to the broker transport. */
|
|
37
|
+
transport?: Transport;
|
|
38
|
+
/**
|
|
39
|
+
* `dev` compiles at -O0, which is roughly three times faster and produces a
|
|
40
|
+
* bigger binary. Only for artifacts that cannot be deployed: `deploy` refuses
|
|
41
|
+
* a host build already, and this defaults to `release` so a caller has to ask
|
|
42
|
+
* for the fast path deliberately.
|
|
43
|
+
*/
|
|
44
|
+
optimize?: "dev" | "release";
|
|
33
45
|
};
|
|
34
46
|
|
|
35
47
|
export type BuildOutput = {
|
|
@@ -37,6 +49,10 @@ export type BuildOutput = {
|
|
|
37
49
|
manifest: ArtifactManifest;
|
|
38
50
|
};
|
|
39
51
|
|
|
52
|
+
/** Baking megabytes of assets into the module makes the Porffor compile crawl;
|
|
53
|
+
* past this it is the wrong tool and the error says what to do instead. */
|
|
54
|
+
const MAX_BAKED_ASSET_BYTES = 8_000_000;
|
|
55
|
+
|
|
40
56
|
function digest(value: Uint8Array | string): `sha256:${string}` {
|
|
41
57
|
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
|
42
58
|
}
|
|
@@ -77,6 +93,7 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
|
|
|
77
93
|
queues: refsByKind.queue.map((ref) => ref.binding),
|
|
78
94
|
analytics: input.config.analytics_engine_datasets ?? [],
|
|
79
95
|
do: Object.entries(input.config.durable_objects ?? {}).map(([binding, className]) => ({ binding, className })),
|
|
96
|
+
services: input.config.services ?? [],
|
|
80
97
|
crons: input.config.triggers?.crons ?? [],
|
|
81
98
|
assets: input.config.assets?.binding ?? "",
|
|
82
99
|
// Baked plain values, read as env.NAME. The broker never serves these (they
|
|
@@ -91,6 +108,43 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
|
|
|
91
108
|
// for it — that download is the slowest part of a first local build.
|
|
92
109
|
const host = input.target === "host";
|
|
93
110
|
const zigBin = host ? undefined : await ensureZig();
|
|
111
|
+
// #15 — an embedded sprout carries its own storage and TLS instead of talking
|
|
112
|
+
// to a broker: SQLite and BearSSL are compiled once per target and added to
|
|
113
|
+
// the link line, and BearSSL's header to the compile line.
|
|
114
|
+
const embedded = input.transport === "embedded";
|
|
115
|
+
const target = input.target ?? "linux-x86_64";
|
|
116
|
+
const sqliteObject = embedded ? await ensureSqliteObject({ target, zigBin }) : null;
|
|
117
|
+
const tls = embedded ? await ensureBearssl({ target, zigBin }) : null;
|
|
118
|
+
const extraLink = [...(sqliteObject ? [sqliteObject] : []), ...(tls ? tls.objects : [])];
|
|
119
|
+
const extraCflags = tls ? ["-I", tls.includeDir] : [];
|
|
120
|
+
// #15 — an embedded binary has no files beside it, so assets are baked into
|
|
121
|
+
// the module. Read them from the source directory: the artifact copy happens
|
|
122
|
+
// after the compile, and the compile is what needs them. Bytes travel as a
|
|
123
|
+
// latin1 string, one char per byte, which is what the asset shim hands back.
|
|
124
|
+
let bakedAssets: { manifest: AssetManifest; files: Record<string, string> } | undefined;
|
|
125
|
+
if (input.transport === "embedded" && input.config.assets) {
|
|
126
|
+
const dir = resolve(input.projectDir, input.config.assets.directory);
|
|
127
|
+
const manifest: AssetManifest = {
|
|
128
|
+
notFound: input.config.assets.not_found_handling ?? "none",
|
|
129
|
+
runSproutFirst: input.config.assets.run_sprout_first ?? false,
|
|
130
|
+
files: walkAssets(dir),
|
|
131
|
+
};
|
|
132
|
+
const files: Record<string, string> = {};
|
|
133
|
+
let total = 0;
|
|
134
|
+
for (const key of Object.keys(manifest.files)) {
|
|
135
|
+
const bytes = await readFile(resolve(dir, `.${key}`));
|
|
136
|
+
total += bytes.byteLength;
|
|
137
|
+
if (total > MAX_BAKED_ASSET_BYTES) {
|
|
138
|
+
throw new Error(
|
|
139
|
+
`assets are too large to compile into a standalone binary (over ${MAX_BAKED_ASSET_BYTES / 1_000_000} MB). ` +
|
|
140
|
+
"Serve them from R2, or drop the assets binding and put a web server in front.",
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
files[key] = bytes.toString("latin1");
|
|
144
|
+
}
|
|
145
|
+
bakedAssets = { manifest, files };
|
|
146
|
+
}
|
|
147
|
+
|
|
94
148
|
await compileSprout({
|
|
95
149
|
sourcePath: input.sourcePath,
|
|
96
150
|
source: input.source,
|
|
@@ -100,6 +154,12 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
|
|
|
100
154
|
zigBin,
|
|
101
155
|
target: input.target,
|
|
102
156
|
compatibilityDate: input.config.compatibility_date,
|
|
157
|
+
transport: input.transport,
|
|
158
|
+
appName: input.config.name,
|
|
159
|
+
assets: bakedAssets,
|
|
160
|
+
extraLink,
|
|
161
|
+
extraCflags,
|
|
162
|
+
optimize: input.optimize,
|
|
103
163
|
});
|
|
104
164
|
|
|
105
165
|
const sprout = await readFile(sproutPath);
|
package/src/compile.ts
CHANGED
|
@@ -12,8 +12,17 @@
|
|
|
12
12
|
import { chmod, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
13
13
|
import { dirname, resolve } from "node:path";
|
|
14
14
|
import { ensurePorfforPatched } from "./patch-porffor";
|
|
15
|
-
import { ensureUWebSockets, porfforRoot, UwsUnavailableError } from "./toolchain";
|
|
16
|
-
import {
|
|
15
|
+
import { ensureUWebSockets, ensureUWebSocketsHost, porfforRoot, UwsUnavailableError } from "./toolchain";
|
|
16
|
+
import {
|
|
17
|
+
EMPTY_BINDINGS,
|
|
18
|
+
preludePath,
|
|
19
|
+
transportPath,
|
|
20
|
+
wrapNativeFetchHandler,
|
|
21
|
+
TRANSPORT_MARKER,
|
|
22
|
+
type Bindings,
|
|
23
|
+
type Transport,
|
|
24
|
+
} from "./wrap";
|
|
25
|
+
export type { Transport } from "./wrap";
|
|
17
26
|
|
|
18
27
|
export {
|
|
19
28
|
BASELINE_COMPATIBILITY_DATE,
|
|
@@ -35,6 +44,26 @@ export type CompileInput = {
|
|
|
35
44
|
/** The project's `compatibility_date`, baked in so the runtime can gate a
|
|
36
45
|
* behaviour change on it. Defaults to the baseline when absent. */
|
|
37
46
|
compatibilityDate?: string;
|
|
47
|
+
/** #15 — which `__sbCall` to compile in. Defaults to the broker transport. */
|
|
48
|
+
transport?: Transport;
|
|
49
|
+
/** #15 — the project name, baked so an embedded build can default its data dir. */
|
|
50
|
+
appName?: string;
|
|
51
|
+
/** #15 — assets baked into the module (embedded builds have no files on disk). */
|
|
52
|
+
assets?: { manifest: unknown; files: Record<string, string> };
|
|
53
|
+
/** #15 — objects to add to the native-fetch link line (SQLite, BearSSL). */
|
|
54
|
+
extraLink?: string[];
|
|
55
|
+
/** #15 — flags for the compile step, so inline C can include BearSSL's header. */
|
|
56
|
+
extraCflags?: string[];
|
|
57
|
+
/**
|
|
58
|
+
* How hard the C compiler works on the generated code.
|
|
59
|
+
*
|
|
60
|
+
* Porffor defaults to `-O3` (compiler/index.js reads `-O` from its own argv),
|
|
61
|
+
* which costs most of the build: `examples/hello` is 8.4s at -O3 against 2.9s
|
|
62
|
+
* at -O0 on an M-series host. `dev` pays that on every edit for a binary that
|
|
63
|
+
* never leaves the machine, so it compiles at -O0 and takes a ~55% larger
|
|
64
|
+
* file. Anything that could be deployed stays at -O3.
|
|
65
|
+
*/
|
|
66
|
+
optimize?: "dev" | "release";
|
|
38
67
|
/** Cross-compiler for `linux-x86_64`. Not needed, and not used, for `host`. */
|
|
39
68
|
zigBin?: string;
|
|
40
69
|
/**
|
|
@@ -46,15 +75,65 @@ export type CompileInput = {
|
|
|
46
75
|
target?: "linux-x86_64" | "host";
|
|
47
76
|
};
|
|
48
77
|
|
|
78
|
+
/**
|
|
79
|
+
* The prelude with a transport spliced in — the exact text the compiler sees.
|
|
80
|
+
*
|
|
81
|
+
* Exported because more than one caller composes a sprout: the build, and the
|
|
82
|
+
* kitchen-sink harness that compiles one itself. Reading
|
|
83
|
+
* `native-fetch-prelude.js` alone yields a module with no `__sbCall` at all.
|
|
84
|
+
*/
|
|
85
|
+
export async function loadPrelude(transport: Transport = "broker"): Promise<string> {
|
|
86
|
+
const [core, chosen] = await Promise.all([readFile(preludePath, "utf8"), readFile(transportPath(transport), "utf8")]);
|
|
87
|
+
if (!core.includes(TRANSPORT_MARKER)) {
|
|
88
|
+
throw new Error("prelude is missing its transport marker — src/native-fetch-prelude.js changed shape");
|
|
89
|
+
}
|
|
90
|
+
return core.replace(TRANSPORT_MARKER, chosen);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Child env for the Porffor run. `SB_EXTRA_LINK` is read by the patched link
|
|
94
|
+
* step (#15) and is absent entirely for a normal build. */
|
|
95
|
+
function compileEnv(path: string, extraLink?: string[], extraCflags?: string[]) {
|
|
96
|
+
const link = extraLink && extraLink.length > 0 ? extraLink.join(" ") : undefined;
|
|
97
|
+
const cflags = extraCflags && extraCflags.length > 0 ? extraCflags.join(" ") : undefined;
|
|
98
|
+
return { ...process.env, PATH: path, SB_EXTRA_LINK: link, SB_EXTRA_CFLAGS: cflags };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The argument list handed to Porffor, minus the launcher.
|
|
103
|
+
*
|
|
104
|
+
* Exported for the test: the one thing worth pinning is that `-O0` reaches only
|
|
105
|
+
* a build that cannot be deployed. `-s` strips at link, because the unstripped
|
|
106
|
+
* static-musl binary is ~90% DWARF that nothing needs at runtime (12 MB down to
|
|
107
|
+
* ~1.3 MB for the kitchen-sink). `--musl` is what makes it a cross-compile; a
|
|
108
|
+
* host build omits it and Porffor targets the machine it runs on.
|
|
109
|
+
*/
|
|
110
|
+
export function porfforArgs(
|
|
111
|
+
generatedPath: string,
|
|
112
|
+
outPath: string,
|
|
113
|
+
target: CompileInput["target"],
|
|
114
|
+
optimize: CompileInput["optimize"],
|
|
115
|
+
): string[] {
|
|
116
|
+
const args = ["native", generatedPath, "-o", outPath];
|
|
117
|
+
if (target !== "host") args.push("--musl");
|
|
118
|
+
args.push("-s");
|
|
119
|
+
// Porffor reads -O from its own argv and defaults to -O3. -O0 is roughly
|
|
120
|
+
// three times faster to compile, so dev takes it and everything else does
|
|
121
|
+
// not: a deployable artifact is never built with it.
|
|
122
|
+
if (optimize === "dev" && target === "host") args.push("-O0");
|
|
123
|
+
return args;
|
|
124
|
+
}
|
|
125
|
+
|
|
49
126
|
/** Compile `sourcePath` to a native binary at `outPath` (mode 0555). */
|
|
50
127
|
export async function compileSprout(input: CompileInput): Promise<void> {
|
|
51
128
|
await ensurePorfforPatched();
|
|
52
129
|
|
|
53
130
|
// Seed the Porffor uWebSockets cache from the prebuilt archive in `vendor/` so
|
|
54
|
-
// the first build needs no `git` / `make`.
|
|
55
|
-
//
|
|
131
|
+
// the first build needs no `git` / `make`. The two targets keep separate
|
|
132
|
+
// cache trees and separate uSockets.a, so seed whichever this build wants.
|
|
133
|
+
// Fall back to Porffor's own git+make path if the archive is unusable.
|
|
56
134
|
try {
|
|
57
|
-
await
|
|
135
|
+
if (input.target === "host") await ensureUWebSocketsHost();
|
|
136
|
+
else await ensureUWebSockets();
|
|
58
137
|
} catch (error) {
|
|
59
138
|
if (!(error instanceof UwsUnavailableError)) throw error;
|
|
60
139
|
const haveGit = Bun.which("git");
|
|
@@ -83,7 +162,7 @@ export async function compileSprout(input: CompileInput): Promise<void> {
|
|
|
83
162
|
const generatedPath = resolve(outDir, "sprout.generated.js");
|
|
84
163
|
const [source, prelude] = await Promise.all([
|
|
85
164
|
input.source === undefined ? readFile(input.sourcePath, "utf8") : Promise.resolve(input.source),
|
|
86
|
-
|
|
165
|
+
loadPrelude(input.transport ?? "broker"),
|
|
87
166
|
]);
|
|
88
167
|
await writeFile(
|
|
89
168
|
generatedPath,
|
|
@@ -94,6 +173,8 @@ export async function compileSprout(input: CompileInput): Promise<void> {
|
|
|
94
173
|
input.bindings ?? EMPTY_BINDINGS,
|
|
95
174
|
undefined,
|
|
96
175
|
input.compatibilityDate,
|
|
176
|
+
input.appName,
|
|
177
|
+
input.assets,
|
|
97
178
|
),
|
|
98
179
|
);
|
|
99
180
|
|
|
@@ -105,15 +186,14 @@ export async function compileSprout(input: CompileInput): Promise<void> {
|
|
|
105
186
|
const zigDir = input.zigBin ? `${dirname(input.zigBin)}:` : "";
|
|
106
187
|
const path = `${zigDir}${binDir}:${process.env.PATH ?? ""}`;
|
|
107
188
|
|
|
108
|
-
// `-s`: strip at link. The unstripped static-musl binary is ~90% DWARF that
|
|
109
|
-
// nothing needs at runtime (12 MB -> ~1.3 MB for the kitchen-sink). Porffor
|
|
110
|
-
// forwards `-s` straight to the `zig cc` link step.
|
|
111
|
-
// `--musl` is what makes it a cross-compile; a host build simply omits it and
|
|
112
|
-
// Porffor targets the machine it is running on.
|
|
113
|
-
const crossFlags = input.target === "host" ? [] : ["--musl"];
|
|
114
189
|
const child = Bun.spawn(
|
|
115
|
-
[process.execPath, launcher,
|
|
116
|
-
{
|
|
190
|
+
[process.execPath, launcher, ...porfforArgs(generatedPath, input.outPath, input.target, input.optimize)],
|
|
191
|
+
{
|
|
192
|
+
cwd: outDir,
|
|
193
|
+
stdout: "pipe",
|
|
194
|
+
stderr: "pipe",
|
|
195
|
+
env: compileEnv(path, input.extraLink, input.extraCflags),
|
|
196
|
+
},
|
|
117
197
|
);
|
|
118
198
|
let timedOut = false;
|
|
119
199
|
const timer = setTimeout(() => {
|
package/src/config.ts
CHANGED
|
@@ -58,6 +58,9 @@ export type SproutboatConfig = {
|
|
|
58
58
|
analytics_engine_datasets?: string[];
|
|
59
59
|
/** Durable Object bindings: `{ BINDING_NAME: "ClassName" }`. The class is defined in the handler module. */
|
|
60
60
|
durable_objects?: Record<string, string>;
|
|
61
|
+
/** #48 — worker-to-worker calls: `env.<BINDING>.fetch(request)` reaches another
|
|
62
|
+
* project on this control plane, resolved to its hostname at activation. */
|
|
63
|
+
services?: Array<{ binding: string; service: string }>;
|
|
61
64
|
/** Scheduled triggers, e.g. `{ "crons": ["0 3 * * *"] }` — a `scheduled(event)` handler runs on each tick. */
|
|
62
65
|
triggers?: { crons?: string[] };
|
|
63
66
|
/** Static assets: a directory served edge-first (like Cloudflare), optionally bound as `env.<BINDING>.fetch(request)`. */
|
|
@@ -114,6 +117,7 @@ function validateConfig(value: ConfigInput): ConfigValidation {
|
|
|
114
117
|
"queues",
|
|
115
118
|
"analytics_engine_datasets",
|
|
116
119
|
"durable_objects",
|
|
120
|
+
"services",
|
|
117
121
|
"triggers",
|
|
118
122
|
"assets",
|
|
119
123
|
]);
|
|
@@ -233,6 +237,25 @@ function validateConfig(value: ConfigInput): ConfigValidation {
|
|
|
233
237
|
}
|
|
234
238
|
}
|
|
235
239
|
|
|
240
|
+
let services: Array<{ binding: string; service: string }> | undefined;
|
|
241
|
+
if (value.services !== undefined) {
|
|
242
|
+
if (!Array.isArray(value.services)) {
|
|
243
|
+
errors.push('services must be an array of { binding: "NAME", service: "project-name" }');
|
|
244
|
+
} else {
|
|
245
|
+
services = [];
|
|
246
|
+
for (const entry of value.services) {
|
|
247
|
+
const row = isRecord(entry) ? entry : null;
|
|
248
|
+
const binding = row && isString(row.binding) ? row.binding : "";
|
|
249
|
+
const service = row && isString(row.service) ? row.service : "";
|
|
250
|
+
if (!bindingName.test(binding) || !isProjectSlug(service)) {
|
|
251
|
+
errors.push('services entries must be { binding: "UPPER_SNAKE", service: "project-name" }');
|
|
252
|
+
} else if (services.some((s) => s.binding === binding)) {
|
|
253
|
+
errors.push(`services: duplicate binding ${binding}`);
|
|
254
|
+
} else services.push({ binding, service });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
236
259
|
let triggers: { crons?: string[] } | undefined;
|
|
237
260
|
if (value.triggers !== undefined) {
|
|
238
261
|
if (!isRecord(value.triggers)) {
|
|
@@ -301,6 +324,7 @@ function validateConfig(value: ConfigInput): ConfigValidation {
|
|
|
301
324
|
...resourceNames(queues),
|
|
302
325
|
...(analytics_engine_datasets ?? []),
|
|
303
326
|
...Object.keys(durable_objects ?? {}),
|
|
327
|
+
...(services ?? []).map((entry) => entry.binding),
|
|
304
328
|
...Object.keys(vars ?? {}),
|
|
305
329
|
...(assets?.binding ? [assets.binding] : []),
|
|
306
330
|
];
|
|
@@ -319,6 +343,7 @@ function validateConfig(value: ConfigInput): ConfigValidation {
|
|
|
319
343
|
if ("queues" in value) config.queues = queues;
|
|
320
344
|
if ("analytics_engine_datasets" in value) config.analytics_engine_datasets = analytics_engine_datasets;
|
|
321
345
|
if ("durable_objects" in value) config.durable_objects = durable_objects;
|
|
346
|
+
if ("services" in value) config.services = services;
|
|
322
347
|
if ("triggers" in value) config.triggers = triggers;
|
|
323
348
|
if ("assets" in value) config.assets = assets;
|
|
324
349
|
return { ok: true, value: config };
|
package/src/dev.ts
CHANGED
|
@@ -85,6 +85,8 @@ async function start(input: DevInput, source: string): Promise<Running> {
|
|
|
85
85
|
sourcePath: input.sourcePath,
|
|
86
86
|
source,
|
|
87
87
|
target: "host",
|
|
88
|
+
// Never deployable, rebuilt on every edit: buy the iteration loop.
|
|
89
|
+
optimize: "dev",
|
|
88
90
|
});
|
|
89
91
|
const artifactDir = artifact.artifactDir;
|
|
90
92
|
const sproutPath = resolve(artifactDir, "sprout");
|
package/src/main.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
-
import {
|
|
2
|
+
import { mkdir, open, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { rmSync } from "node:fs";
|
|
4
|
+
import { basename, dirname, relative, resolve } from "node:path";
|
|
4
5
|
import { isBoolean, isSafeInteger, isString, jsonObject, parseJsonValue, type JsonObject } from "./json";
|
|
5
6
|
import type { AssetFiles } from "./assets";
|
|
6
7
|
import { parseConfig, pinBindingId, resourceRefs, type SproutboatConfig } from "./config";
|
|
7
8
|
import { validateHttpSyncSource } from "./source";
|
|
8
9
|
import { buildArtifact } from "./build";
|
|
10
|
+
import { generateTypes, TYPES_FILE } from "./types-gen";
|
|
9
11
|
import { bundleHandler, BundleError, type BundleResult } from "./bundle";
|
|
10
12
|
import { runDev } from "./dev";
|
|
13
|
+
import { buildStandalone } from "./standalone-build";
|
|
11
14
|
import { hostTarget, validateManifest, type ArtifactManifest } from "./manifest";
|
|
12
15
|
import { CLI_VERSION, printDeployReport } from "./report";
|
|
13
16
|
import { activeApiUrl, forgetToken, savedToken, saveToken } from "./credentials";
|
|
@@ -230,6 +233,35 @@ async function check(directory?: string) {
|
|
|
230
233
|
console.log(ok(`check passed — ${project.config.name} (${project.config.main}, native-fetch)`));
|
|
231
234
|
}
|
|
232
235
|
|
|
236
|
+
async function types(directory?: string) {
|
|
237
|
+
const project = await readProject(directory);
|
|
238
|
+
const path = resolve(project.directory, TYPES_FILE);
|
|
239
|
+
await writeFile(path, generateTypes(project.config));
|
|
240
|
+
console.log(ok(`wrote ${relative(process.cwd(), path) || TYPES_FILE}`));
|
|
241
|
+
console.log(dim(" Commit it. Add it to tsconfig `include` if your project has one."));
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Rewrite the generated types when the config has moved on, and only then.
|
|
246
|
+
*
|
|
247
|
+
* Called from `dev` and `deploy` so a binding added to sproutboat.jsonc cannot
|
|
248
|
+
* sit unreflected in the editor. It writes only when the content actually
|
|
249
|
+
* differs, which keeps it out of the way of git and of file watchers.
|
|
250
|
+
*/
|
|
251
|
+
async function refreshTypes(projectDir: string, config: SproutboatConfig): Promise<void> {
|
|
252
|
+
const path = resolve(projectDir, TYPES_FILE);
|
|
253
|
+
const wanted = generateTypes(config);
|
|
254
|
+
try {
|
|
255
|
+
if ((await readFile(path, "utf8")) === wanted) return;
|
|
256
|
+
} catch {
|
|
257
|
+
// No file yet: only write one if the project already opted in by having a
|
|
258
|
+
// tsconfig. Generating into a plain JavaScript project is noise.
|
|
259
|
+
if (!(await Bun.file(resolve(projectDir, "tsconfig.json")).exists())) return;
|
|
260
|
+
}
|
|
261
|
+
await writeFile(path, wanted);
|
|
262
|
+
console.log(dim(` ${TYPES_FILE} refreshed from sproutboat.jsonc`));
|
|
263
|
+
}
|
|
264
|
+
|
|
233
265
|
async function build(directory?: string, target: "linux-x86_64" | "host" = "linux-x86_64") {
|
|
234
266
|
const project = await readProject(directory);
|
|
235
267
|
console.log(
|
|
@@ -251,6 +283,32 @@ async function build(directory?: string, target: "linux-x86_64" | "host" = "linu
|
|
|
251
283
|
return { project, artifact };
|
|
252
284
|
}
|
|
253
285
|
|
|
286
|
+
/**
|
|
287
|
+
* #15 — one executable carrying the sprout, its assets and its bindings.
|
|
288
|
+
*
|
|
289
|
+
* Defaults to a host build: the point is a binary you can run right here. Pass
|
|
290
|
+
* `--target linux-x86_64` for a box, but note the Bun launcher is compiled for
|
|
291
|
+
* *this* machine either way in phase 0 — cross-compiling both halves comes with
|
|
292
|
+
* the embedded backend, which removes the launcher entirely.
|
|
293
|
+
*/
|
|
294
|
+
async function buildStandaloneBinary(directory: string | undefined, target: "linux-x86_64" | "host") {
|
|
295
|
+
const project = await readProject(directory);
|
|
296
|
+
console.log(dim(`Building a standalone ${project.config.name} (${target})…`));
|
|
297
|
+
try {
|
|
298
|
+
const result = await buildStandalone({
|
|
299
|
+
projectDir: project.directory,
|
|
300
|
+
config: project.config,
|
|
301
|
+
sourcePath: project.sourcePath,
|
|
302
|
+
source: project.bundle.code,
|
|
303
|
+
target,
|
|
304
|
+
});
|
|
305
|
+
console.log(ok(`built ${result.outPath} (${(result.bytes / 1_000_000).toFixed(1)} MB)`));
|
|
306
|
+
console.log(dim(` run it: ${result.outPath} · state lands in ./${project.config.name}.data`));
|
|
307
|
+
} catch (error) {
|
|
308
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
254
312
|
/** #62 — build for this machine, run it against a real broker, rebuild on save. */
|
|
255
313
|
async function dev(args: string[]) {
|
|
256
314
|
const directory = args.find((arg) => !arg.startsWith("--") && !/^\d+$/.test(arg));
|
|
@@ -260,6 +318,7 @@ async function dev(args: string[]) {
|
|
|
260
318
|
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535)
|
|
261
319
|
usageError(`invalid --port: ${portArg}`, "dev [project-dir] [--port <n>] [--no-watch]");
|
|
262
320
|
const project = await readProject(directory);
|
|
321
|
+
await refreshTypes(project.directory, project.config);
|
|
263
322
|
console.log(dim(`Building ${project.config.name} for this machine (${hostTarget()})…`));
|
|
264
323
|
await runDev({
|
|
265
324
|
projectDir: project.directory,
|
|
@@ -383,6 +442,8 @@ async function deploy(args: string[]) {
|
|
|
383
442
|
projectName = built.project.config.name;
|
|
384
443
|
artifactDir = built.artifact.artifactDir;
|
|
385
444
|
config = built.project.config;
|
|
445
|
+
// After provisioning: ids may have been pinned into the config just now.
|
|
446
|
+
await refreshTypes(built.project.directory, built.project.config);
|
|
386
447
|
}
|
|
387
448
|
const manifest = Bun.file(resolve(artifactDir, "manifest.json"));
|
|
388
449
|
const sprout = Bun.file(resolve(artifactDir, "sprout"));
|
|
@@ -870,6 +931,194 @@ async function storage(key: string, args: string[]) {
|
|
|
870
931
|
console.log(ok(`deleted ${product.noun} ${name}`));
|
|
871
932
|
}
|
|
872
933
|
|
|
934
|
+
function option(args: string[], name: string): string | undefined {
|
|
935
|
+
const index = args.indexOf(name);
|
|
936
|
+
return index >= 0 ? args[index + 1] : undefined;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
async function writeAtomic(
|
|
940
|
+
path: string,
|
|
941
|
+
chunks: AsyncIterable<string> | readonly string[],
|
|
942
|
+
overwrite: boolean,
|
|
943
|
+
): Promise<void> {
|
|
944
|
+
const destination = resolve(path);
|
|
945
|
+
if (!overwrite && (await Bun.file(destination).exists())) fail(`${path} already exists; pass --force to replace it`);
|
|
946
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
947
|
+
const temporary = `${destination}.tmp-${process.pid}-${Date.now()}`;
|
|
948
|
+
const cleanup = () => {
|
|
949
|
+
try {
|
|
950
|
+
rmSync(temporary, { force: true });
|
|
951
|
+
} catch {
|
|
952
|
+
/* best effort during process shutdown */
|
|
953
|
+
}
|
|
954
|
+
};
|
|
955
|
+
process.once("exit", cleanup);
|
|
956
|
+
const file = await open(temporary, "wx", 0o600);
|
|
957
|
+
try {
|
|
958
|
+
for await (const chunk of chunks) await file.write(chunk);
|
|
959
|
+
await file.sync();
|
|
960
|
+
await file.close();
|
|
961
|
+
await rename(temporary, destination);
|
|
962
|
+
process.off("exit", cleanup);
|
|
963
|
+
} catch (error) {
|
|
964
|
+
await file.close().catch(() => undefined);
|
|
965
|
+
cleanup();
|
|
966
|
+
process.off("exit", cleanup);
|
|
967
|
+
throw error;
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
async function kvContents(args: string[]): Promise<void> {
|
|
972
|
+
const area = args.shift();
|
|
973
|
+
if (!area || !["key", "bulk", "export"].includes(area))
|
|
974
|
+
usageError("kv: unknown contents command", "kv <key | bulk | export> ...");
|
|
975
|
+
const namespace = area === "export" ? args.shift() : args[1];
|
|
976
|
+
if (!namespace) usageError(`kv ${area}: missing <namespace>`, `kv ${area} <namespace> ...`);
|
|
977
|
+
const { apiUrl, token } = await apiCredentials();
|
|
978
|
+
const rows = await storageRows(`${apiUrl}/api/kv`, { "x-api-key": token }, STORAGE_PRODUCTS[0]);
|
|
979
|
+
const id = idForName(rows, namespace, STORAGE_PRODUCTS[0]);
|
|
980
|
+
const base = `${apiUrl}/api/kv/${id}`;
|
|
981
|
+
const auth = { "x-api-key": token };
|
|
982
|
+
|
|
983
|
+
if (area === "key") {
|
|
984
|
+
const verb = args.shift();
|
|
985
|
+
args.shift();
|
|
986
|
+
if (!verb || !["list", "get", "put", "delete"].includes(verb))
|
|
987
|
+
usageError("kv key: unknown operation", "kv key <list | get | put | delete> <namespace> ...");
|
|
988
|
+
if (verb === "list") {
|
|
989
|
+
const query = new URLSearchParams();
|
|
990
|
+
for (const flag of ["--prefix", "--cursor", "--limit"] as const) {
|
|
991
|
+
const value = option(args, flag);
|
|
992
|
+
if (value !== undefined) query.set(flag.slice(2), value);
|
|
993
|
+
}
|
|
994
|
+
const body = await responseText(await fetch(`${base}/keys?${query}`, { headers: auth }), "list rejected");
|
|
995
|
+
console.log(body);
|
|
996
|
+
return;
|
|
997
|
+
}
|
|
998
|
+
const key = args.shift();
|
|
999
|
+
if (!key) usageError(`kv key ${verb}: missing <key>`, `kv key ${verb} <namespace> <key>`);
|
|
1000
|
+
const url = `${base}/keys/${encodeURIComponent(key)}`;
|
|
1001
|
+
if (verb === "get") {
|
|
1002
|
+
const body = await responseText(await fetch(url, { headers: auth }), "get rejected");
|
|
1003
|
+
const record = jsonObject(parseJsonValue(body));
|
|
1004
|
+
if (!record || !isString(record.value)) fail("get response did not contain a value");
|
|
1005
|
+
const output = option(args, "--output");
|
|
1006
|
+
if (output) await writeAtomic(output, [record.value], args.includes("--force"));
|
|
1007
|
+
else console.log(args.includes("--text") ? record.value : JSON.stringify({ key, value: record.value }, null, 2));
|
|
1008
|
+
return;
|
|
1009
|
+
}
|
|
1010
|
+
if (verb === "put") {
|
|
1011
|
+
const path = option(args, "--path");
|
|
1012
|
+
const positional = args.find((arg, index) => !arg.startsWith("-") && args[index - 1] !== "--path");
|
|
1013
|
+
if (path && positional)
|
|
1014
|
+
usageError(
|
|
1015
|
+
"kv key put: use a value or --path, not both",
|
|
1016
|
+
"kv key put <namespace> <key> [value] [--path <file>]",
|
|
1017
|
+
);
|
|
1018
|
+
const value = path ? await readFile(resolve(path), "utf8") : (positional ?? (await Bun.stdin.text()));
|
|
1019
|
+
await responseText(
|
|
1020
|
+
await fetch(url, {
|
|
1021
|
+
method: "PUT",
|
|
1022
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
1023
|
+
body: JSON.stringify({ value }),
|
|
1024
|
+
}),
|
|
1025
|
+
"put rejected",
|
|
1026
|
+
);
|
|
1027
|
+
console.log(ok(`set ${key}`));
|
|
1028
|
+
return;
|
|
1029
|
+
}
|
|
1030
|
+
if (!args.includes("--yes")) fail(`this permanently deletes "${key}"; re-run with --yes`);
|
|
1031
|
+
await responseText(await fetch(url, { method: "DELETE", headers: auth }), "delete rejected");
|
|
1032
|
+
console.log(ok(`deleted ${key}`));
|
|
1033
|
+
return;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
if (area === "bulk") {
|
|
1037
|
+
const verb = args.shift();
|
|
1038
|
+
args.shift();
|
|
1039
|
+
const input = args.shift();
|
|
1040
|
+
if (!verb || !["get", "put", "delete"].includes(verb) || !input)
|
|
1041
|
+
usageError(
|
|
1042
|
+
"kv bulk: expected an operation, namespace, and JSON file",
|
|
1043
|
+
"kv bulk <get | put | delete> <namespace> <file>",
|
|
1044
|
+
);
|
|
1045
|
+
if (verb === "delete" && !args.includes("--yes")) fail("bulk deletion requires --yes");
|
|
1046
|
+
const parsed = parseJsonValue(await readFile(resolve(input), "utf8"));
|
|
1047
|
+
if (!Array.isArray(parsed) || parsed.length === 0) fail("bulk input must be a non-empty JSON array");
|
|
1048
|
+
const responses: unknown[] = [];
|
|
1049
|
+
let changed = 0;
|
|
1050
|
+
const failures: unknown[] = [];
|
|
1051
|
+
for (let start = 0; start < parsed.length; start += 100) {
|
|
1052
|
+
const body = await responseText(
|
|
1053
|
+
await fetch(`${base}/bulk/${verb}`, {
|
|
1054
|
+
method: "POST",
|
|
1055
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
1056
|
+
body: JSON.stringify(parsed.slice(start, start + 100)),
|
|
1057
|
+
}),
|
|
1058
|
+
`bulk ${verb} rejected`,
|
|
1059
|
+
);
|
|
1060
|
+
const result = parseJsonValue(body);
|
|
1061
|
+
if (verb === "get" && Array.isArray(result)) responses.push(...result);
|
|
1062
|
+
else {
|
|
1063
|
+
const summary = jsonObject(result);
|
|
1064
|
+
changed += Number(summary?.written ?? summary?.deleted ?? 0);
|
|
1065
|
+
if (Array.isArray(summary?.failures)) failures.push(...summary.failures);
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
const result = verb === "get" ? responses : { [verb === "put" ? "written" : "deleted"]: changed, failures };
|
|
1069
|
+
const output = option(args, "--output");
|
|
1070
|
+
if (output) await writeAtomic(output, [JSON.stringify(result, null, 2) + "\n"], args.includes("--force"));
|
|
1071
|
+
else console.log(JSON.stringify(result, null, 2));
|
|
1072
|
+
return;
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
const output = option(args, "--output");
|
|
1076
|
+
if (!output)
|
|
1077
|
+
usageError(
|
|
1078
|
+
"kv export: missing --output <file>",
|
|
1079
|
+
"kv export <namespace> --output <dump.json> [--prefix <prefix>] [--force]",
|
|
1080
|
+
);
|
|
1081
|
+
const prefix = option(args, "--prefix") ?? "";
|
|
1082
|
+
async function* dump(): AsyncGenerator<string> {
|
|
1083
|
+
yield "[\n";
|
|
1084
|
+
let cursor = "";
|
|
1085
|
+
let first = true;
|
|
1086
|
+
do {
|
|
1087
|
+
const query = new URLSearchParams({ prefix, limit: "100" });
|
|
1088
|
+
if (cursor) query.set("cursor", cursor);
|
|
1089
|
+
const listed = jsonObject(
|
|
1090
|
+
parseJsonValue(
|
|
1091
|
+
await responseText(await fetch(`${base}/keys?${query}`, { headers: auth }), "export list rejected"),
|
|
1092
|
+
),
|
|
1093
|
+
);
|
|
1094
|
+
const keys = Array.isArray(listed?.keys) ? listed.keys.filter(isString) : [];
|
|
1095
|
+
if (keys.length) {
|
|
1096
|
+
const values = parseJsonValue(
|
|
1097
|
+
await responseText(
|
|
1098
|
+
await fetch(`${base}/bulk/get`, {
|
|
1099
|
+
method: "POST",
|
|
1100
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
1101
|
+
body: JSON.stringify(keys),
|
|
1102
|
+
}),
|
|
1103
|
+
"export read rejected",
|
|
1104
|
+
),
|
|
1105
|
+
);
|
|
1106
|
+
if (!Array.isArray(values)) fail("export response was not an array");
|
|
1107
|
+
for (const entry of values) {
|
|
1108
|
+
const record = jsonObject(entry);
|
|
1109
|
+
if (!record || !isString(record.key) || !isString(record.value)) continue;
|
|
1110
|
+
yield `${first ? "" : ",\n"} ${JSON.stringify({ key: record.key, value: record.value })}`;
|
|
1111
|
+
first = false;
|
|
1112
|
+
}
|
|
1113
|
+
}
|
|
1114
|
+
cursor = isString(listed?.cursor) ? listed.cursor : "";
|
|
1115
|
+
} while (cursor);
|
|
1116
|
+
yield "\n]\n";
|
|
1117
|
+
}
|
|
1118
|
+
await writeAtomic(output, dump(), args.includes("--force"));
|
|
1119
|
+
console.log(ok(`exported ${namespace} to ${output}`));
|
|
1120
|
+
}
|
|
1121
|
+
|
|
873
1122
|
async function deleteProject(args: string[]) {
|
|
874
1123
|
// `sproutboat delete [project-dir] [--name <project>] --yes` — flags in any order.
|
|
875
1124
|
const positional: string[] = [];
|
|
@@ -936,15 +1185,17 @@ switch (command) {
|
|
|
936
1185
|
case "check":
|
|
937
1186
|
await check(args[0]);
|
|
938
1187
|
break;
|
|
1188
|
+
case "types":
|
|
1189
|
+
await types(args[0]);
|
|
1190
|
+
break;
|
|
939
1191
|
case "dev":
|
|
940
1192
|
await dev(args);
|
|
941
1193
|
break;
|
|
942
1194
|
case "build": {
|
|
943
1195
|
const hostBuild = args.includes("--target") && args[args.indexOf("--target") + 1] === "host";
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
);
|
|
1196
|
+
const directory = args.find((arg) => !arg.startsWith("--") && arg !== "host");
|
|
1197
|
+
if (args.includes("--standalone")) await buildStandaloneBinary(directory, hostBuild ? "host" : "linux-x86_64");
|
|
1198
|
+
else await build(directory, hostBuild ? "host" : "linux-x86_64");
|
|
948
1199
|
break;
|
|
949
1200
|
}
|
|
950
1201
|
case "login":
|
|
@@ -972,6 +1223,9 @@ switch (command) {
|
|
|
972
1223
|
await secrets(args);
|
|
973
1224
|
break;
|
|
974
1225
|
case "kv":
|
|
1226
|
+
if (["key", "bulk", "export"].includes(args[0] ?? "")) await kvContents(args);
|
|
1227
|
+
else await storage(command, args);
|
|
1228
|
+
break;
|
|
975
1229
|
case "d1":
|
|
976
1230
|
case "r2":
|
|
977
1231
|
case "queues":
|