sproutboat 0.4.11 → 0.6.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/README.md +74 -55
- package/SURFACE.md +13 -7
- package/package.json +28 -20
- package/src/assets.ts +29 -9
- package/src/broker.ts +188 -87
- package/src/build.ts +42 -7
- package/src/bundle.ts +70 -0
- package/src/compile.ts +37 -9
- package/src/config.ts +72 -30
- package/src/credentials.ts +19 -2
- package/src/dev.ts +213 -0
- package/src/json.ts +38 -0
- package/src/main.ts +473 -126
- package/src/manifest.ts +81 -14
- package/src/native-fetch-prelude.js +274 -135
- package/src/patch-porffor.ts +5 -2
- package/src/report.ts +39 -17
- package/src/source.ts +20 -2
- package/src/style.ts +9 -6
- package/src/surface.ts +195 -42
- package/src/toolchain.ts +21 -7
- package/src/update-check.ts +33 -9
- package/src/wrap.ts +71 -17
package/src/bundle.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #89 — bundle the handler before Porffor sees it.
|
|
3
|
+
*
|
|
4
|
+
* Porffor compiles one self-contained module, so until now a project was one
|
|
5
|
+
* file with no imports: no router, no validation library, no SDK, no splitting
|
|
6
|
+
* a codebase in two. Bundling first lifts that without touching the compiler.
|
|
7
|
+
*
|
|
8
|
+
* Bun's bundler resolves relative imports across the project and bare
|
|
9
|
+
* specifiers out of the project's own `node_modules`, then emits a single ESM
|
|
10
|
+
* module. The capability bans (`process`, `Bun`, `node:`, WebSocket, …) are
|
|
11
|
+
* checked against that output rather than the entry file, so a dependency
|
|
12
|
+
* reaching for a Node API fails exactly as user code would.
|
|
13
|
+
*/
|
|
14
|
+
import { relative } from "node:path";
|
|
15
|
+
|
|
16
|
+
export type BundleResult = {
|
|
17
|
+
/** One self-contained ESM module: what gets validated, hashed, and compiled. */
|
|
18
|
+
code: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export class BundleError extends Error {
|
|
22
|
+
constructor(message: string) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "BundleError";
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const entryLabel = (entryPath: string, projectDir: string): string => relative(projectDir, entryPath) || entryPath;
|
|
29
|
+
|
|
30
|
+
/** Bun reports resolution failures on `AggregateError.errors`; its own message is just "Bundle failed". */
|
|
31
|
+
function bundleDetail(cause: unknown): string {
|
|
32
|
+
const errors = cause instanceof AggregateError ? cause.errors : [];
|
|
33
|
+
if (errors.length > 0)
|
|
34
|
+
return errors.map((error) => ` ${error instanceof Error ? error.message : String(error)}`).join("\n");
|
|
35
|
+
return ` ${cause instanceof Error ? cause.message : String(cause)}`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Bundle `entryPath` into a single module, resolving imports from `projectDir`. */
|
|
39
|
+
export async function bundleHandler(entryPath: string, projectDir: string): Promise<BundleResult> {
|
|
40
|
+
let built: Awaited<ReturnType<typeof Bun.build>>;
|
|
41
|
+
try {
|
|
42
|
+
built = await Bun.build({
|
|
43
|
+
entrypoints: [entryPath],
|
|
44
|
+
root: projectDir,
|
|
45
|
+
// `browser` keeps the output free of Node shims — a dependency that wants
|
|
46
|
+
// `process` must fail the capability check, not get a polyfill smuggled in.
|
|
47
|
+
target: "browser",
|
|
48
|
+
format: "esm",
|
|
49
|
+
// Readability over size: a compile error from Porffor should point at
|
|
50
|
+
// something a human can find, and Porffor strips the binary anyway.
|
|
51
|
+
minify: false,
|
|
52
|
+
splitting: false,
|
|
53
|
+
sourcemap: "none",
|
|
54
|
+
});
|
|
55
|
+
} catch (cause) {
|
|
56
|
+
// An unresolvable specifier arrives as an AggregateError whose `errors`
|
|
57
|
+
// carry the useful part ("Could not resolve: ./missing.js"); the top-level
|
|
58
|
+
// message is only "Bundle failed", which tells nobody which import broke.
|
|
59
|
+
throw new BundleError(`could not bundle ${entryLabel(entryPath, projectDir)}:\n${bundleDetail(cause)}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!built.success) {
|
|
63
|
+
const detail = built.logs.map((log) => ` ${log.message}`).join("\n");
|
|
64
|
+
throw new BundleError(`could not bundle ${entryLabel(entryPath, projectDir)}:\n${detail}`);
|
|
65
|
+
}
|
|
66
|
+
const [output] = built.outputs;
|
|
67
|
+
if (output === undefined) throw new BundleError("the bundler produced no output");
|
|
68
|
+
|
|
69
|
+
return { code: await output.text() };
|
|
70
|
+
}
|
package/src/compile.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* shipped in `vendor/` so this needs no `git` or `make`; if that archive is
|
|
10
10
|
* unusable it falls back to Porffor's own git + make path (needs both on PATH).
|
|
11
11
|
*/
|
|
12
|
-
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
|
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
15
|
import { ensureUWebSockets, porfforRoot, UwsUnavailableError } from "./toolchain";
|
|
@@ -21,10 +21,20 @@ const COMPILE_TIMEOUT_MS = Number(process.env.SPROUTBOAT_COMPILE_TIMEOUT_MS || 6
|
|
|
21
21
|
|
|
22
22
|
export type CompileInput = {
|
|
23
23
|
sourcePath: string;
|
|
24
|
+
/** The bundled module (#89). Falls back to reading `sourcePath` verbatim. */
|
|
25
|
+
source?: string;
|
|
24
26
|
outPath: string;
|
|
25
27
|
vars: Record<string, string>;
|
|
26
28
|
bindings?: Bindings;
|
|
27
|
-
|
|
29
|
+
/** Cross-compiler for `linux-x86_64`. Not needed, and not used, for `host`. */
|
|
30
|
+
zigBin?: string;
|
|
31
|
+
/**
|
|
32
|
+
* `linux-x86_64` (default) cross-compiles the static musl binary every box
|
|
33
|
+
* runs. `host` compiles for the machine doing the build (#62) so `sproutboat
|
|
34
|
+
* dev` can actually serve a sprout on a developer's laptop — an arm64 Mac
|
|
35
|
+
* cannot execute the deploy artifact.
|
|
36
|
+
*/
|
|
37
|
+
target?: "linux-x86_64" | "host";
|
|
28
38
|
};
|
|
29
39
|
|
|
30
40
|
/** Compile `sourcePath` to a native binary at `outPath` (mode 0555). */
|
|
@@ -41,38 +51,56 @@ export async function compileSprout(input: CompileInput): Promise<void> {
|
|
|
41
51
|
const haveGit = Bun.which("git");
|
|
42
52
|
const haveMake = Bun.which("make");
|
|
43
53
|
if (haveGit && haveMake) {
|
|
44
|
-
console.warn(
|
|
54
|
+
console.warn(
|
|
55
|
+
`prebuilt uWebSockets unusable (${error.message.split("\n")[0]}); falling back to git + make (slower, one-time)`,
|
|
56
|
+
);
|
|
45
57
|
} else {
|
|
46
58
|
const missing = [!haveGit && "git", !haveMake && "make"].filter(Boolean).join(" and ");
|
|
47
59
|
throw new Error(
|
|
48
60
|
`${error.message}\n\nThe prebuilt uWebSockets is unusable, and ${missing} ` +
|
|
49
|
-
|
|
50
|
-
|
|
61
|
+
`${missing.includes("and") ? "are" : "is"} not on PATH for the fallback build. ` +
|
|
62
|
+
`Install ${missing}, or set SPROUTBOAT_UWS_TARBALL to a valid archive.`,
|
|
51
63
|
);
|
|
52
64
|
}
|
|
53
65
|
}
|
|
54
66
|
|
|
55
67
|
const outDir = dirname(input.outPath);
|
|
56
68
|
await mkdir(outDir, { recursive: true });
|
|
69
|
+
// The artifact dir is content-addressed, so rebuilding unchanged source lands
|
|
70
|
+
// on the previous binary — which `chmod 0555` left read-only, and which the
|
|
71
|
+
// OS may still be executing. The linker cannot overwrite either, so clear it
|
|
72
|
+
// first rather than failing with "can't write output file".
|
|
73
|
+
await rm(input.outPath, { force: true });
|
|
57
74
|
const generatedPath = resolve(outDir, "sprout.generated.js");
|
|
58
|
-
const [source, prelude] = await Promise.all([
|
|
75
|
+
const [source, prelude] = await Promise.all([
|
|
76
|
+
input.source === undefined ? readFile(input.sourcePath, "utf8") : Promise.resolve(input.source),
|
|
77
|
+
readFile(preludePath, "utf8"),
|
|
78
|
+
]);
|
|
59
79
|
await writeFile(generatedPath, wrapNativeFetchHandler(source, prelude, input.vars, input.bindings ?? EMPTY_BINDINGS));
|
|
60
80
|
|
|
61
81
|
const porffor = porfforRoot();
|
|
62
82
|
const launcher = resolve(porffor, "runtime/index.js");
|
|
63
83
|
// Porffor shells bare `zig` and `esbuild`; put both on PATH for the child.
|
|
84
|
+
// A host build never shells `zig`, so it has no zigBin to contribute.
|
|
64
85
|
const binDir = resolve(porffor, "../.bin");
|
|
65
|
-
const
|
|
86
|
+
const zigDir = input.zigBin ? `${dirname(input.zigBin)}:` : "";
|
|
87
|
+
const path = `${zigDir}${binDir}:${process.env.PATH ?? ""}`;
|
|
66
88
|
|
|
67
89
|
// `-s`: strip at link. The unstripped static-musl binary is ~90% DWARF that
|
|
68
90
|
// nothing needs at runtime (12 MB -> ~1.3 MB for the kitchen-sink). Porffor
|
|
69
91
|
// forwards `-s` straight to the `zig cc` link step.
|
|
92
|
+
// `--musl` is what makes it a cross-compile; a host build simply omits it and
|
|
93
|
+
// Porffor targets the machine it is running on.
|
|
94
|
+
const crossFlags = input.target === "host" ? [] : ["--musl"];
|
|
70
95
|
const child = Bun.spawn(
|
|
71
|
-
[process.execPath, launcher, "native", generatedPath, "-o", input.outPath,
|
|
96
|
+
[process.execPath, launcher, "native", generatedPath, "-o", input.outPath, ...crossFlags, "-s"],
|
|
72
97
|
{ cwd: outDir, stdout: "pipe", stderr: "pipe", env: { ...process.env, PATH: path } },
|
|
73
98
|
);
|
|
74
99
|
let timedOut = false;
|
|
75
|
-
const timer = setTimeout(() => {
|
|
100
|
+
const timer = setTimeout(() => {
|
|
101
|
+
timedOut = true;
|
|
102
|
+
child.kill();
|
|
103
|
+
}, COMPILE_TIMEOUT_MS);
|
|
76
104
|
const [code, stdout, stderr] = await Promise.all([
|
|
77
105
|
child.exited,
|
|
78
106
|
new Response(child.stdout).text(),
|
package/src/config.ts
CHANGED
|
@@ -4,14 +4,16 @@ const slugPattern = /^[a-z0-9](?:[a-z0-9-]{1,30}[a-z0-9])?$/;
|
|
|
4
4
|
* A storage binding entry (#74). Either a bare `"BINDING"` — resolved to an
|
|
5
5
|
* ephemeral local resource for `sproutboat dev`, rejected by a real deploy — or
|
|
6
6
|
* `{ binding, id }` pointing at an account-level resource created with
|
|
7
|
-
* `sproutboat
|
|
7
|
+
* `sproutboat <kv|d1|r2|queues> create`. The id carries its own `<kind>_` prefix.
|
|
8
8
|
*/
|
|
9
9
|
export type ResourceBinding = { binding: string; id: string };
|
|
10
10
|
export type ResourceRef = string | ResourceBinding;
|
|
11
11
|
|
|
12
12
|
/** Normalizes a storage-binding array to `{ binding, id? }` rows. */
|
|
13
13
|
export function resourceRefs(field: readonly ResourceRef[] | undefined): Array<{ binding: string; id?: string }> {
|
|
14
|
-
return (field ?? []).map((entry) =>
|
|
14
|
+
return (field ?? []).map((entry) =>
|
|
15
|
+
isString(entry) ? { binding: entry } : { binding: entry.binding, id: entry.id },
|
|
16
|
+
);
|
|
15
17
|
}
|
|
16
18
|
|
|
17
19
|
/**
|
|
@@ -73,9 +75,7 @@ export type AssetsConfig = {
|
|
|
73
75
|
run_sprout_first?: boolean | string[];
|
|
74
76
|
};
|
|
75
77
|
|
|
76
|
-
export type ConfigValidation =
|
|
77
|
-
| { ok: true; value: SproutboatConfig }
|
|
78
|
-
| { ok: false; errors: string[] };
|
|
78
|
+
export type ConfigValidation = { ok: true; value: SproutboatConfig } | { ok: false; errors: string[] };
|
|
79
79
|
|
|
80
80
|
type JsonValue = string | number | boolean | null | ConfigJsonObject | JsonValue[];
|
|
81
81
|
|
|
@@ -86,8 +86,7 @@ interface ConfigJsonObject {
|
|
|
86
86
|
type ConfigInput = JsonValue | undefined;
|
|
87
87
|
|
|
88
88
|
function isRecord(value: ConfigInput): value is ConfigJsonObject {
|
|
89
|
-
return value !== null && Object(value) === value && !Array.isArray(value)
|
|
90
|
-
&& !(value instanceof Function);
|
|
89
|
+
return value !== null && Object(value) === value && !Array.isArray(value) && !(value instanceof Function);
|
|
91
90
|
}
|
|
92
91
|
|
|
93
92
|
function isString(value: ConfigInput): value is string {
|
|
@@ -102,9 +101,21 @@ function validateConfig(value: ConfigInput): ConfigValidation {
|
|
|
102
101
|
const errors: string[] = [];
|
|
103
102
|
if (!isRecord(value)) return { ok: false, errors: ["config must be an object"] };
|
|
104
103
|
const allowed = new Set([
|
|
105
|
-
"$schema",
|
|
106
|
-
"
|
|
107
|
-
"
|
|
104
|
+
"$schema",
|
|
105
|
+
"name",
|
|
106
|
+
"main",
|
|
107
|
+
"compatibility_date",
|
|
108
|
+
"vars",
|
|
109
|
+
"kv_namespaces",
|
|
110
|
+
"secrets",
|
|
111
|
+
"outbound",
|
|
112
|
+
"d1_databases",
|
|
113
|
+
"r2_buckets",
|
|
114
|
+
"queues",
|
|
115
|
+
"analytics_engine_datasets",
|
|
116
|
+
"durable_objects",
|
|
117
|
+
"triggers",
|
|
118
|
+
"assets",
|
|
108
119
|
]);
|
|
109
120
|
for (const key of Object.keys(value)) if (!allowed.has(key)) errors.push(`unsupported config field: ${key}`);
|
|
110
121
|
const name = isString(value.name) && isProjectSlug(value.name) ? value.name : null;
|
|
@@ -115,7 +126,10 @@ function validateConfig(value: ConfigInput): ConfigValidation {
|
|
|
115
126
|
if (main === null) {
|
|
116
127
|
errors.push("main must be a relative entry point under src/");
|
|
117
128
|
}
|
|
118
|
-
const compatibility_date =
|
|
129
|
+
const compatibility_date =
|
|
130
|
+
isString(value.compatibility_date) && /^\d{4}-\d{2}-\d{2}$/.test(value.compatibility_date)
|
|
131
|
+
? value.compatibility_date
|
|
132
|
+
: null;
|
|
119
133
|
if (compatibility_date === null) {
|
|
120
134
|
errors.push("compatibility_date must use YYYY-MM-DD");
|
|
121
135
|
}
|
|
@@ -127,7 +141,8 @@ function validateConfig(value: ConfigInput): ConfigValidation {
|
|
|
127
141
|
else {
|
|
128
142
|
vars = {};
|
|
129
143
|
for (const [key, item] of Object.entries(value.vars)) {
|
|
130
|
-
if (!/^[A-Z][A-Z0-9_]*$/.test(key) || !isString(item))
|
|
144
|
+
if (!/^[A-Z][A-Z0-9_]*$/.test(key) || !isString(item))
|
|
145
|
+
errors.push(`vars.${key} must be a string environment name`);
|
|
131
146
|
else vars[key] = item;
|
|
132
147
|
}
|
|
133
148
|
}
|
|
@@ -174,9 +189,14 @@ function validateConfig(value: ConfigInput): ConfigValidation {
|
|
|
174
189
|
if (isString(entry)) {
|
|
175
190
|
if (bindingName.test(entry)) out.push(entry);
|
|
176
191
|
else errors.push(`${field}: "${entry}" must be an UPPER_SNAKE binding name`);
|
|
177
|
-
} else if (
|
|
178
|
-
|
|
179
|
-
|
|
192
|
+
} else if (
|
|
193
|
+
isRecord(entry) &&
|
|
194
|
+
isString(entry.binding) &&
|
|
195
|
+
isString(entry.id) &&
|
|
196
|
+
bindingName.test(entry.binding) &&
|
|
197
|
+
idPattern.test(entry.id) &&
|
|
198
|
+
Object.keys(entry).every((key) => key === "binding" || key === "id")
|
|
199
|
+
) {
|
|
180
200
|
out.push({ binding: entry.binding, id: entry.id });
|
|
181
201
|
} else {
|
|
182
202
|
errors.push(`${field} entries must be an UPPER_SNAKE name or { binding: "NAME", id: "${kind}_…" }`);
|
|
@@ -189,7 +209,11 @@ function validateConfig(value: ConfigInput): ConfigValidation {
|
|
|
189
209
|
const outbound = stringArray("outbound", hostPattern, "hostnames");
|
|
190
210
|
// Analytics Engine datasets aren't provisioned — the dataset name springs into
|
|
191
211
|
// existence on first writeDataPoint(), so there's no resource id to bind (#74).
|
|
192
|
-
const analytics_engine_datasets = stringArray(
|
|
212
|
+
const analytics_engine_datasets = stringArray(
|
|
213
|
+
"analytics_engine_datasets",
|
|
214
|
+
bindingName,
|
|
215
|
+
"binding names (UPPER_SNAKE_CASE)",
|
|
216
|
+
);
|
|
193
217
|
const kv_namespaces = resourceArray("kv_namespaces", "kv");
|
|
194
218
|
const d1_databases = resourceArray("d1_databases", "d1");
|
|
195
219
|
const r2_buckets = resourceArray("r2_buckets", "r2");
|
|
@@ -198,7 +222,7 @@ function validateConfig(value: ConfigInput): ConfigValidation {
|
|
|
198
222
|
let durable_objects: Record<string, string> | undefined;
|
|
199
223
|
if (value.durable_objects !== undefined) {
|
|
200
224
|
if (!isRecord(value.durable_objects)) {
|
|
201
|
-
errors.push(
|
|
225
|
+
errors.push('durable_objects must be an object of { BINDING_NAME: "ClassName" }');
|
|
202
226
|
} else {
|
|
203
227
|
durable_objects = {};
|
|
204
228
|
for (const [binding, className] of Object.entries(value.durable_objects)) {
|
|
@@ -230,15 +254,24 @@ function validateConfig(value: ConfigInput): ConfigValidation {
|
|
|
230
254
|
errors.push("assets must be an object with a `directory`");
|
|
231
255
|
} else {
|
|
232
256
|
const raw = value.assets;
|
|
233
|
-
const dir =
|
|
234
|
-
|
|
235
|
-
|
|
257
|
+
const dir =
|
|
258
|
+
isString(raw.directory) && raw.directory.length > 0 && !raw.directory.includes("..")
|
|
259
|
+
? raw.directory.replace(/^\.\//, "").replace(/\/$/, "")
|
|
260
|
+
: null;
|
|
236
261
|
if (dir === null) errors.push("assets.directory must be a project-relative path");
|
|
237
|
-
const binding =
|
|
238
|
-
|
|
262
|
+
const binding =
|
|
263
|
+
raw.binding === undefined
|
|
264
|
+
? undefined
|
|
265
|
+
: isString(raw.binding) && bindingName.test(raw.binding)
|
|
266
|
+
? raw.binding
|
|
267
|
+
: null;
|
|
239
268
|
if (binding === null) errors.push("assets.binding must be a binding name (UPPER_SNAKE_CASE)");
|
|
240
|
-
const nfh =
|
|
241
|
-
|
|
269
|
+
const nfh =
|
|
270
|
+
raw.not_found_handling === "none" ||
|
|
271
|
+
raw.not_found_handling === "single-page-application" ||
|
|
272
|
+
raw.not_found_handling === "404-page"
|
|
273
|
+
? raw.not_found_handling
|
|
274
|
+
: undefined;
|
|
242
275
|
if (raw.not_found_handling !== undefined && nfh === undefined) {
|
|
243
276
|
errors.push('assets.not_found_handling must be "none", "single-page-application", or "404-page"');
|
|
244
277
|
}
|
|
@@ -259,16 +292,22 @@ function validateConfig(value: ConfigInput): ConfigValidation {
|
|
|
259
292
|
}
|
|
260
293
|
}
|
|
261
294
|
|
|
262
|
-
const resourceNames = (refs: ResourceRef[] | undefined): string[] =>
|
|
263
|
-
resourceRefs(refs).map((ref) => ref.binding);
|
|
295
|
+
const resourceNames = (refs: ResourceRef[] | undefined): string[] => resourceRefs(refs).map((ref) => ref.binding);
|
|
264
296
|
const bindingSlots = [
|
|
265
|
-
...resourceNames(kv_namespaces),
|
|
266
|
-
...
|
|
297
|
+
...resourceNames(kv_namespaces),
|
|
298
|
+
...(secrets ?? []),
|
|
299
|
+
...resourceNames(d1_databases),
|
|
300
|
+
...resourceNames(r2_buckets),
|
|
301
|
+
...resourceNames(queues),
|
|
302
|
+
...(analytics_engine_datasets ?? []),
|
|
303
|
+
...Object.keys(durable_objects ?? {}),
|
|
304
|
+
...Object.keys(vars ?? {}),
|
|
267
305
|
...(assets?.binding ? [assets.binding] : []),
|
|
268
306
|
];
|
|
269
307
|
if (new Set(bindingSlots).size !== bindingSlots.length) errors.push("vars and binding names must not collide");
|
|
270
308
|
|
|
271
|
-
if (errors.length || name === null || main === null || compatibility_date === null || schema === null)
|
|
309
|
+
if (errors.length || name === null || main === null || compatibility_date === null || schema === null)
|
|
310
|
+
return { ok: false, errors };
|
|
272
311
|
const config: SproutboatConfig = { name, main, compatibility_date };
|
|
273
312
|
if ("$schema" in value) config.$schema = schema;
|
|
274
313
|
if ("vars" in value) config.vars = vars;
|
|
@@ -307,6 +346,9 @@ export function parseConfig(source: string): ConfigValidation {
|
|
|
307
346
|
json = json.replace(/,\s*([}\]])/g, "$1");
|
|
308
347
|
return validateConfig(JSON.parse(json));
|
|
309
348
|
} catch (error) {
|
|
310
|
-
return {
|
|
349
|
+
return {
|
|
350
|
+
ok: false,
|
|
351
|
+
errors: [`invalid sproutboat.jsonc: ${error instanceof Error ? error.message : String(error)}`],
|
|
352
|
+
};
|
|
311
353
|
}
|
|
312
354
|
}
|
package/src/credentials.ts
CHANGED
|
@@ -59,12 +59,29 @@ export async function activeApiUrl(): Promise<string | undefined> {
|
|
|
59
59
|
return (await readCredentials()).activeApiUrl;
|
|
60
60
|
}
|
|
61
61
|
|
|
62
|
+
/** #79 `logout`: drop one endpoint's token. Returns false when none was stored. */
|
|
63
|
+
export async function forgetToken(apiUrl: string): Promise<boolean> {
|
|
64
|
+
const credentials = await readCredentials();
|
|
65
|
+
if (!credentials.profiles[apiUrl]) return false;
|
|
66
|
+
delete credentials.profiles[apiUrl];
|
|
67
|
+
if (credentials.activeApiUrl === apiUrl) {
|
|
68
|
+
credentials.activeApiUrl = Object.keys(credentials.profiles)[0];
|
|
69
|
+
}
|
|
70
|
+
await writeCredentials(credentials);
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
62
74
|
export async function saveToken(apiUrl: string, token: string): Promise<void> {
|
|
63
|
-
const directory = configDirectory();
|
|
64
|
-
const path = credentialsPath();
|
|
65
75
|
const credentials = await readCredentials();
|
|
66
76
|
credentials.profiles[apiUrl] = { token };
|
|
67
77
|
credentials.activeApiUrl = apiUrl;
|
|
78
|
+
await writeCredentials(credentials);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Atomic 0600 write of the whole credentials file. */
|
|
82
|
+
async function writeCredentials(credentials: Credentials): Promise<void> {
|
|
83
|
+
const directory = configDirectory();
|
|
84
|
+
const path = credentialsPath();
|
|
68
85
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
69
86
|
await chmod(directory, 0o700);
|
|
70
87
|
const temporary = `${path}.tmp-${crypto.randomUUID()}`;
|
package/src/dev.ts
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #62 — `sproutboat dev`: the project running on this machine, rebuilt on save.
|
|
3
|
+
*
|
|
4
|
+
* The platform's own local stack (control + edge + supervisor) exists to serve
|
|
5
|
+
* *deployed* artifacts, which are linux-x86_64 and cannot execute on a laptop.
|
|
6
|
+
* This is the other half: build for the host (#62), stand up the same broker
|
|
7
|
+
* the supervisor would, and run the sprout against it — so `env.KV`, secrets,
|
|
8
|
+
* cron and the rest behave the way they will in production without a deploy.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately not the platform: no control plane, no TLS, no routing. One
|
|
11
|
+
* project, one port.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, watch, type FSWatcher } from "node:fs";
|
|
14
|
+
import { mkdir, readFile } from "node:fs/promises";
|
|
15
|
+
import { dirname, resolve } from "node:path";
|
|
16
|
+
import { buildArtifact } from "./build";
|
|
17
|
+
import { createBroker, listen, type Bindings, type Broker } from "./broker";
|
|
18
|
+
import { jsonObject, parseJsonValue } from "./json";
|
|
19
|
+
import { amber, dim, leaf, ok } from "./style";
|
|
20
|
+
import type { SproutboatConfig } from "./config";
|
|
21
|
+
|
|
22
|
+
const RESTART_DEBOUNCE_MS = 120;
|
|
23
|
+
|
|
24
|
+
export type DevInput = {
|
|
25
|
+
projectDir: string;
|
|
26
|
+
config: SproutboatConfig;
|
|
27
|
+
sourcePath: string;
|
|
28
|
+
/** The bundled module (#89) — already validated by the caller. */
|
|
29
|
+
source: string;
|
|
30
|
+
port: number;
|
|
31
|
+
watch: boolean;
|
|
32
|
+
/** Re-bundle and re-validate after a file changes; throws with a readable message. */
|
|
33
|
+
rebuild: () => Promise<string>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Secrets for local dev, `KEY=value` per line, from `.dev.vars` beside the
|
|
38
|
+
* config — the same file Wrangler uses. Deployed secrets live in the control
|
|
39
|
+
* plane and are never on a developer's disk, so this is the only way a bound
|
|
40
|
+
* secret can resolve here.
|
|
41
|
+
*/
|
|
42
|
+
async function readDevVars(projectDir: string): Promise<Record<string, string>> {
|
|
43
|
+
const path = resolve(projectDir, ".dev.vars");
|
|
44
|
+
if (!existsSync(path)) return {};
|
|
45
|
+
const text = await readFile(path, "utf8");
|
|
46
|
+
return Object.fromEntries(
|
|
47
|
+
text.split("\n").flatMap((line): Array<[string, string]> => {
|
|
48
|
+
const trimmed = line.trim();
|
|
49
|
+
if (trimmed === "" || trimmed.startsWith("#")) return [];
|
|
50
|
+
const eq = trimmed.indexOf("=");
|
|
51
|
+
if (eq <= 0) return [];
|
|
52
|
+
const value = trimmed.slice(eq + 1).trim();
|
|
53
|
+
// Accept quoted values, since a secret can legitimately contain spaces.
|
|
54
|
+
const unquoted =
|
|
55
|
+
(value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))
|
|
56
|
+
? value.slice(1, -1)
|
|
57
|
+
: value;
|
|
58
|
+
return [[trimmed.slice(0, eq).trim(), unquoted]];
|
|
59
|
+
}),
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** `bindings.json` is written by the build whenever the project declares any. */
|
|
64
|
+
async function readBindings(artifactDir: string): Promise<Partial<Bindings> | undefined> {
|
|
65
|
+
const path = resolve(artifactDir, "bindings.json");
|
|
66
|
+
if (!existsSync(path)) return undefined;
|
|
67
|
+
const record = jsonObject(parseJsonValue(await readFile(path, "utf8")));
|
|
68
|
+
// SAFETY: written by `buildArtifact` in this process moments ago, from the
|
|
69
|
+
// Bindings shape; the broker re-validates every field it reads anyway.
|
|
70
|
+
return record as Partial<Bindings> | undefined;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
type Running = {
|
|
74
|
+
sprout: Bun.Subprocess;
|
|
75
|
+
broker: Broker;
|
|
76
|
+
stopBroker: () => void;
|
|
77
|
+
/** Set before a kill we initiated, so its exit code is not reported as a crash. */
|
|
78
|
+
expected: boolean;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
async function start(input: DevInput, source: string): Promise<Running> {
|
|
82
|
+
const artifact = await buildArtifact({
|
|
83
|
+
projectDir: input.projectDir,
|
|
84
|
+
config: input.config,
|
|
85
|
+
sourcePath: input.sourcePath,
|
|
86
|
+
source,
|
|
87
|
+
target: "host",
|
|
88
|
+
});
|
|
89
|
+
const artifactDir = artifact.artifactDir;
|
|
90
|
+
const sproutPath = resolve(artifactDir, "sprout");
|
|
91
|
+
|
|
92
|
+
// `new Database(path, { create: true })` creates the file, never the
|
|
93
|
+
// directory above it, so a first run would fail with SQLITE_CANTOPEN.
|
|
94
|
+
const stateDir = resolve(input.projectDir, ".sproutboat/dev");
|
|
95
|
+
await mkdir(stateDir, { recursive: true });
|
|
96
|
+
const assetsDir = resolve(artifactDir, "assets");
|
|
97
|
+
const broker = createBroker({
|
|
98
|
+
db: resolve(stateDir, "state.sqlite"),
|
|
99
|
+
dataDir: resolve(stateDir, "d1"),
|
|
100
|
+
resourceDir: resolve(stateDir, "resources"),
|
|
101
|
+
token: "sproutboat-dev",
|
|
102
|
+
bindings: await readBindings(artifactDir),
|
|
103
|
+
secrets: await readDevVars(input.projectDir),
|
|
104
|
+
sproutUrl: `http://127.0.0.1:${input.port}/`,
|
|
105
|
+
assetsDir: existsSync(assetsDir) ? assetsDir : undefined,
|
|
106
|
+
});
|
|
107
|
+
const server = listen(broker, "127.0.0.1", 0);
|
|
108
|
+
|
|
109
|
+
const sprout = Bun.spawn([sproutPath], {
|
|
110
|
+
cwd: dirname(sproutPath),
|
|
111
|
+
env: {
|
|
112
|
+
...process.env,
|
|
113
|
+
PORT: String(input.port),
|
|
114
|
+
SB_BROKER_PORT: String(server.port),
|
|
115
|
+
SB_BROKER_TOKEN: "sproutboat-dev",
|
|
116
|
+
},
|
|
117
|
+
stdout: "inherit",
|
|
118
|
+
stderr: "inherit",
|
|
119
|
+
});
|
|
120
|
+
return {
|
|
121
|
+
sprout,
|
|
122
|
+
broker,
|
|
123
|
+
stopBroker: () => {
|
|
124
|
+
server.stop();
|
|
125
|
+
broker.close();
|
|
126
|
+
},
|
|
127
|
+
expected: false,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function stop(running: Running): void {
|
|
132
|
+
running.expected = true;
|
|
133
|
+
running.sprout.kill(9);
|
|
134
|
+
running.stopBroker();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Report a sprout that died on its own; a kill we asked for is not news. */
|
|
138
|
+
function watchExit(running: Running): void {
|
|
139
|
+
void running.sprout.exited.then((code) => {
|
|
140
|
+
if (running.expected || code === 0) return;
|
|
141
|
+
console.error(amber(`sprout exited with status ${code} — fix it and save to rebuild`));
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Build, run, and (optionally) rebuild on change. Resolves only on shutdown. */
|
|
146
|
+
export async function runDev(input: DevInput): Promise<void> {
|
|
147
|
+
let running = await start(input, input.source);
|
|
148
|
+
watchExit(running);
|
|
149
|
+
console.log(ok(`${input.config.name} running on ${leaf(`http://127.0.0.1:${input.port}`)}`));
|
|
150
|
+
if (input.watch) console.log(dim(" watching for changes — ctrl-c to stop"));
|
|
151
|
+
|
|
152
|
+
const watchers: FSWatcher[] = [];
|
|
153
|
+
let shuttingDown = false;
|
|
154
|
+
let resolveShutdown: (() => void) | null = null;
|
|
155
|
+
const shutdown = () => {
|
|
156
|
+
if (shuttingDown) return;
|
|
157
|
+
shuttingDown = true;
|
|
158
|
+
for (const watcher of watchers) watcher.close();
|
|
159
|
+
stop(running);
|
|
160
|
+
resolveShutdown?.();
|
|
161
|
+
process.exit(0);
|
|
162
|
+
};
|
|
163
|
+
for (const signal of ["SIGINT", "SIGTERM"] as const) process.on(signal, shutdown);
|
|
164
|
+
|
|
165
|
+
if (input.watch) {
|
|
166
|
+
let pending: ReturnType<typeof setTimeout> | null = null;
|
|
167
|
+
let rebuilding = false;
|
|
168
|
+
const onChange = () => {
|
|
169
|
+
if (pending !== null) clearTimeout(pending);
|
|
170
|
+
// Editors write a file in several syscalls; one save should be one build.
|
|
171
|
+
pending = setTimeout(() => {
|
|
172
|
+
void (async () => {
|
|
173
|
+
if (rebuilding || shuttingDown) return;
|
|
174
|
+
rebuilding = true;
|
|
175
|
+
try {
|
|
176
|
+
const source = await input.rebuild();
|
|
177
|
+
console.log(dim(" change detected, rebuilding…"));
|
|
178
|
+
stop(running);
|
|
179
|
+
running = await start(input, source);
|
|
180
|
+
watchExit(running);
|
|
181
|
+
console.log(ok(` reloaded on http://127.0.0.1:${input.port}`));
|
|
182
|
+
} catch (cause) {
|
|
183
|
+
// Keep the last good build serving; a typo should not take the
|
|
184
|
+
// server down mid-edit.
|
|
185
|
+
console.error(
|
|
186
|
+
amber(
|
|
187
|
+
` rebuild failed, still serving the previous build:\n ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
188
|
+
),
|
|
189
|
+
);
|
|
190
|
+
} finally {
|
|
191
|
+
rebuilding = false;
|
|
192
|
+
}
|
|
193
|
+
})();
|
|
194
|
+
}, RESTART_DEBOUNCE_MS);
|
|
195
|
+
};
|
|
196
|
+
// The entry's directory covers the usual `src/` layout; the config itself
|
|
197
|
+
// changes bindings, so it needs a rebuild too.
|
|
198
|
+
watchers.push(watch(dirname(input.sourcePath), { recursive: true }, onChange));
|
|
199
|
+
watchers.push(watch(resolve(input.projectDir, "sproutboat.jsonc"), onChange));
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Watching, we stay up until a signal: a crashed sprout is something to fix
|
|
203
|
+
// and save, not a reason to tear the whole session down. Without a watcher
|
|
204
|
+
// there is nothing to wait for but this one process.
|
|
205
|
+
if (input.watch) {
|
|
206
|
+
await new Promise<void>((resolve) => {
|
|
207
|
+
resolveShutdown = resolve;
|
|
208
|
+
});
|
|
209
|
+
} else {
|
|
210
|
+
await running.sprout.exited;
|
|
211
|
+
stop(running);
|
|
212
|
+
}
|
|
213
|
+
}
|
package/src/json.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one JSON contract the CLI decodes external payloads through: registry
|
|
3
|
+
* responses, control-plane responses, broker request bodies. Parse at the I/O
|
|
4
|
+
* boundary with `parseJsonValue`, then narrow with these guards — nothing
|
|
5
|
+
* downstream should see an unparsed value.
|
|
6
|
+
*/
|
|
7
|
+
export type JsonValue = string | number | boolean | null | JsonObject | JsonValue[];
|
|
8
|
+
export type JsonObject = { [key: string]: JsonValue };
|
|
9
|
+
|
|
10
|
+
export function isString(value: JsonValue | undefined): value is string {
|
|
11
|
+
return value !== undefined && value === String(value);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function isSafeInteger(value: JsonValue | undefined): value is number {
|
|
15
|
+
return Number.isSafeInteger(value);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function isBoolean(value: JsonValue | undefined): value is boolean {
|
|
19
|
+
return value === true || value === false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function parseJsonValue(source: string): JsonValue {
|
|
23
|
+
const value = JSON.parse(source);
|
|
24
|
+
if (
|
|
25
|
+
value === null ||
|
|
26
|
+
value === true ||
|
|
27
|
+
value === false ||
|
|
28
|
+
value === String(value) ||
|
|
29
|
+
Number.isFinite(value) ||
|
|
30
|
+
value instanceof Object
|
|
31
|
+
)
|
|
32
|
+
return value;
|
|
33
|
+
throw new Error("response was not valid JSON");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function jsonObject(value: JsonValue): JsonObject | undefined {
|
|
37
|
+
return value instanceof Object && !Array.isArray(value) ? value : undefined;
|
|
38
|
+
}
|