sproutboat 0.4.10 → 0.5.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 +82 -30
- package/SURFACE.md +13 -7
- package/package.json +4 -1
- package/src/broker.ts +47 -24
- package/src/build.ts +21 -4
- package/src/bundle.ts +69 -0
- package/src/compile.ts +28 -5
- package/src/config.ts +1 -1
- package/src/credentials.ts +19 -2
- package/src/dev.ts +196 -0
- package/src/json.ts +30 -0
- package/src/main.ts +229 -79
- package/src/manifest.ts +21 -3
- package/src/native-fetch-prelude.js +32 -22
- package/src/report.ts +3 -1
- package/src/source.ts +17 -2
- package/src/surface.ts +57 -12
- package/src/toolchain.ts +6 -3
- package/src/update-check.ts +13 -4
- package/src/wrap.ts +43 -13
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). */
|
|
@@ -54,21 +64,34 @@ export async function compileSprout(input: CompileInput): Promise<void> {
|
|
|
54
64
|
|
|
55
65
|
const outDir = dirname(input.outPath);
|
|
56
66
|
await mkdir(outDir, { recursive: true });
|
|
67
|
+
// The artifact dir is content-addressed, so rebuilding unchanged source lands
|
|
68
|
+
// on the previous binary — which `chmod 0555` left read-only, and which the
|
|
69
|
+
// OS may still be executing. The linker cannot overwrite either, so clear it
|
|
70
|
+
// first rather than failing with "can't write output file".
|
|
71
|
+
await rm(input.outPath, { force: true });
|
|
57
72
|
const generatedPath = resolve(outDir, "sprout.generated.js");
|
|
58
|
-
const [source, prelude] = await Promise.all([
|
|
73
|
+
const [source, prelude] = await Promise.all([
|
|
74
|
+
input.source === undefined ? readFile(input.sourcePath, "utf8") : Promise.resolve(input.source),
|
|
75
|
+
readFile(preludePath, "utf8"),
|
|
76
|
+
]);
|
|
59
77
|
await writeFile(generatedPath, wrapNativeFetchHandler(source, prelude, input.vars, input.bindings ?? EMPTY_BINDINGS));
|
|
60
78
|
|
|
61
79
|
const porffor = porfforRoot();
|
|
62
80
|
const launcher = resolve(porffor, "runtime/index.js");
|
|
63
81
|
// Porffor shells bare `zig` and `esbuild`; put both on PATH for the child.
|
|
82
|
+
// A host build never shells `zig`, so it has no zigBin to contribute.
|
|
64
83
|
const binDir = resolve(porffor, "../.bin");
|
|
65
|
-
const
|
|
84
|
+
const zigDir = input.zigBin ? `${dirname(input.zigBin)}:` : "";
|
|
85
|
+
const path = `${zigDir}${binDir}:${process.env.PATH ?? ""}`;
|
|
66
86
|
|
|
67
87
|
// `-s`: strip at link. The unstripped static-musl binary is ~90% DWARF that
|
|
68
88
|
// nothing needs at runtime (12 MB -> ~1.3 MB for the kitchen-sink). Porffor
|
|
69
89
|
// forwards `-s` straight to the `zig cc` link step.
|
|
90
|
+
// `--musl` is what makes it a cross-compile; a host build simply omits it and
|
|
91
|
+
// Porffor targets the machine it is running on.
|
|
92
|
+
const crossFlags = input.target === "host" ? [] : ["--musl"];
|
|
70
93
|
const child = Bun.spawn(
|
|
71
|
-
[process.execPath, launcher, "native", generatedPath, "-o", input.outPath,
|
|
94
|
+
[process.execPath, launcher, "native", generatedPath, "-o", input.outPath, ...crossFlags, "-s"],
|
|
72
95
|
{ cwd: outDir, stdout: "pipe", stderr: "pipe", env: { ...process.env, PATH: path } },
|
|
73
96
|
);
|
|
74
97
|
let timedOut = false;
|
package/src/config.ts
CHANGED
|
@@ -4,7 +4,7 @@ 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;
|
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,196 @@
|
|
|
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 { isString, 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(text.split("\n").flatMap((line): Array<[string, string]> => {
|
|
47
|
+
const trimmed = line.trim();
|
|
48
|
+
if (trimmed === "" || trimmed.startsWith("#")) return [];
|
|
49
|
+
const eq = trimmed.indexOf("=");
|
|
50
|
+
if (eq <= 0) return [];
|
|
51
|
+
const value = trimmed.slice(eq + 1).trim();
|
|
52
|
+
// Accept quoted values, since a secret can legitimately contain spaces.
|
|
53
|
+
const unquoted = (value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))
|
|
54
|
+
? value.slice(1, -1)
|
|
55
|
+
: value;
|
|
56
|
+
return [[trimmed.slice(0, eq).trim(), unquoted]];
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** `bindings.json` is written by the build whenever the project declares any. */
|
|
61
|
+
async function readBindings(artifactDir: string): Promise<Partial<Bindings> | undefined> {
|
|
62
|
+
const path = resolve(artifactDir, "bindings.json");
|
|
63
|
+
if (!existsSync(path)) return undefined;
|
|
64
|
+
const record = jsonObject(parseJsonValue(await readFile(path, "utf8")));
|
|
65
|
+
// SAFETY: written by `buildArtifact` in this process moments ago, from the
|
|
66
|
+
// Bindings shape; the broker re-validates every field it reads anyway.
|
|
67
|
+
return record as Partial<Bindings> | undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type Running = {
|
|
71
|
+
sprout: Bun.Subprocess;
|
|
72
|
+
broker: Broker;
|
|
73
|
+
stopBroker: () => void;
|
|
74
|
+
/** Set before a kill we initiated, so its exit code is not reported as a crash. */
|
|
75
|
+
expected: boolean;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
async function start(input: DevInput, source: string): Promise<Running> {
|
|
79
|
+
const artifact = await buildArtifact({
|
|
80
|
+
projectDir: input.projectDir,
|
|
81
|
+
config: input.config,
|
|
82
|
+
sourcePath: input.sourcePath,
|
|
83
|
+
source,
|
|
84
|
+
target: "host",
|
|
85
|
+
});
|
|
86
|
+
const artifactDir = artifact.artifactDir;
|
|
87
|
+
const sproutPath = resolve(artifactDir, "sprout");
|
|
88
|
+
|
|
89
|
+
// `new Database(path, { create: true })` creates the file, never the
|
|
90
|
+
// directory above it, so a first run would fail with SQLITE_CANTOPEN.
|
|
91
|
+
const stateDir = resolve(input.projectDir, ".sproutboat/dev");
|
|
92
|
+
await mkdir(stateDir, { recursive: true });
|
|
93
|
+
const assetsDir = resolve(artifactDir, "assets");
|
|
94
|
+
const broker = createBroker({
|
|
95
|
+
db: resolve(stateDir, "state.sqlite"),
|
|
96
|
+
dataDir: resolve(stateDir, "d1"),
|
|
97
|
+
resourceDir: resolve(stateDir, "resources"),
|
|
98
|
+
token: "sproutboat-dev",
|
|
99
|
+
bindings: await readBindings(artifactDir),
|
|
100
|
+
secrets: await readDevVars(input.projectDir),
|
|
101
|
+
sproutUrl: `http://127.0.0.1:${input.port}/`,
|
|
102
|
+
assetsDir: existsSync(assetsDir) ? assetsDir : undefined,
|
|
103
|
+
});
|
|
104
|
+
const server = listen(broker, "127.0.0.1", 0);
|
|
105
|
+
|
|
106
|
+
const sprout = Bun.spawn([sproutPath], {
|
|
107
|
+
cwd: dirname(sproutPath),
|
|
108
|
+
env: {
|
|
109
|
+
...process.env,
|
|
110
|
+
PORT: String(input.port),
|
|
111
|
+
SB_BROKER_PORT: String(server.port),
|
|
112
|
+
SB_BROKER_TOKEN: "sproutboat-dev",
|
|
113
|
+
},
|
|
114
|
+
stdout: "inherit",
|
|
115
|
+
stderr: "inherit",
|
|
116
|
+
});
|
|
117
|
+
return { sprout, broker, stopBroker: () => { server.stop(); broker.close(); }, expected: false };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function stop(running: Running): void {
|
|
121
|
+
running.expected = true;
|
|
122
|
+
running.sprout.kill(9);
|
|
123
|
+
running.stopBroker();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Report a sprout that died on its own; a kill we asked for is not news. */
|
|
127
|
+
function watchExit(running: Running): void {
|
|
128
|
+
void running.sprout.exited.then((code) => {
|
|
129
|
+
if (running.expected || code === 0) return;
|
|
130
|
+
console.error(amber(`sprout exited with status ${code} — fix it and save to rebuild`));
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Build, run, and (optionally) rebuild on change. Resolves only on shutdown. */
|
|
135
|
+
export async function runDev(input: DevInput): Promise<void> {
|
|
136
|
+
let running = await start(input, input.source);
|
|
137
|
+
watchExit(running);
|
|
138
|
+
console.log(ok(`${input.config.name} running on ${leaf(`http://127.0.0.1:${input.port}`)}`));
|
|
139
|
+
if (input.watch) console.log(dim(" watching for changes — ctrl-c to stop"));
|
|
140
|
+
|
|
141
|
+
const watchers: FSWatcher[] = [];
|
|
142
|
+
let shuttingDown = false;
|
|
143
|
+
let resolveShutdown: (() => void) | null = null;
|
|
144
|
+
const shutdown = () => {
|
|
145
|
+
if (shuttingDown) return;
|
|
146
|
+
shuttingDown = true;
|
|
147
|
+
for (const watcher of watchers) watcher.close();
|
|
148
|
+
stop(running);
|
|
149
|
+
resolveShutdown?.();
|
|
150
|
+
process.exit(0);
|
|
151
|
+
};
|
|
152
|
+
for (const signal of ["SIGINT", "SIGTERM"] as const) process.on(signal, shutdown);
|
|
153
|
+
|
|
154
|
+
if (input.watch) {
|
|
155
|
+
let pending: ReturnType<typeof setTimeout> | null = null;
|
|
156
|
+
let rebuilding = false;
|
|
157
|
+
const onChange = () => {
|
|
158
|
+
if (pending !== null) clearTimeout(pending);
|
|
159
|
+
// Editors write a file in several syscalls; one save should be one build.
|
|
160
|
+
pending = setTimeout(() => {
|
|
161
|
+
void (async () => {
|
|
162
|
+
if (rebuilding || shuttingDown) return;
|
|
163
|
+
rebuilding = true;
|
|
164
|
+
try {
|
|
165
|
+
const source = await input.rebuild();
|
|
166
|
+
console.log(dim(" change detected, rebuilding…"));
|
|
167
|
+
stop(running);
|
|
168
|
+
running = await start(input, source);
|
|
169
|
+
watchExit(running);
|
|
170
|
+
console.log(ok(` reloaded on http://127.0.0.1:${input.port}`));
|
|
171
|
+
} catch (cause) {
|
|
172
|
+
// Keep the last good build serving; a typo should not take the
|
|
173
|
+
// server down mid-edit.
|
|
174
|
+
console.error(amber(` rebuild failed, still serving the previous build:\n ${cause instanceof Error ? cause.message : String(cause)}`));
|
|
175
|
+
} finally {
|
|
176
|
+
rebuilding = false;
|
|
177
|
+
}
|
|
178
|
+
})();
|
|
179
|
+
}, RESTART_DEBOUNCE_MS);
|
|
180
|
+
};
|
|
181
|
+
// The entry's directory covers the usual `src/` layout; the config itself
|
|
182
|
+
// changes bindings, so it needs a rebuild too.
|
|
183
|
+
watchers.push(watch(dirname(input.sourcePath), { recursive: true }, onChange));
|
|
184
|
+
watchers.push(watch(resolve(input.projectDir, "sproutboat.jsonc"), onChange));
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// Watching, we stay up until a signal: a crashed sprout is something to fix
|
|
188
|
+
// and save, not a reason to tear the whole session down. Without a watcher
|
|
189
|
+
// there is nothing to wait for but this one process.
|
|
190
|
+
if (input.watch) {
|
|
191
|
+
await new Promise<void>((resolve) => { resolveShutdown = resolve; });
|
|
192
|
+
} else {
|
|
193
|
+
await running.sprout.exited;
|
|
194
|
+
stop(running);
|
|
195
|
+
}
|
|
196
|
+
}
|
package/src/json.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
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 (value === null || value === true || value === false || value === String(value) || Number.isFinite(value) || value instanceof Object) return value;
|
|
25
|
+
throw new Error("response was not valid JSON");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function jsonObject(value: JsonValue): JsonObject | undefined {
|
|
29
|
+
return value instanceof Object && !Array.isArray(value) ? value : undefined;
|
|
30
|
+
}
|