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/patch-porffor.ts
CHANGED
|
@@ -29,12 +29,15 @@ export async function ensurePorfforPatched(): Promise<void> {
|
|
|
29
29
|
if (done) return;
|
|
30
30
|
const file = resolve(porfforRoot(), "compiler/render.js");
|
|
31
31
|
const src = await readFile(file, "utf8");
|
|
32
|
-
if (src.includes(MARKER)) {
|
|
32
|
+
if (src.includes(MARKER)) {
|
|
33
|
+
done = true;
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
33
36
|
const anchorAt = src.indexOf(ANCHOR);
|
|
34
37
|
if (anchorAt === -1) {
|
|
35
38
|
throw new Error(
|
|
36
39
|
`could not patch Porffor for $PORT: anchor not found in ${file}. ` +
|
|
37
|
-
|
|
40
|
+
"Porffor's native-fetch renderer changed — check patches/UPSTREAM.md.",
|
|
38
41
|
);
|
|
39
42
|
}
|
|
40
43
|
const patched = src.slice(0, anchorAt + ANCHOR.length) + INJECT + src.slice(anchorAt + ANCHOR.length);
|
package/src/report.ts
CHANGED
|
@@ -5,7 +5,11 @@ import type { ArtifactManifest } from "./manifest";
|
|
|
5
5
|
import { bold, dim, leaf, sprout } from "./style";
|
|
6
6
|
|
|
7
7
|
// Read from package.json so the banner never drifts from the published version.
|
|
8
|
-
|
|
8
|
+
// SAFETY: our own package.json, shipped beside src/ by the `files` field; npm requires `version`.
|
|
9
|
+
const packageJson = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as {
|
|
10
|
+
version: string;
|
|
11
|
+
};
|
|
12
|
+
export const CLI_VERSION = packageJson.version;
|
|
9
13
|
|
|
10
14
|
function bytes(n: number): string {
|
|
11
15
|
if (n < 1024) return `${n} B`;
|
|
@@ -19,23 +23,38 @@ function table(headers: string[], rows: string[][], align: boolean[] = []): stri
|
|
|
19
23
|
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)));
|
|
20
24
|
const rule = (l: string, m: string, r: string) => leaf(l + widths.map((w) => "─".repeat(w + 2)).join(m) + r);
|
|
21
25
|
const row = (cells: string[], head = false) =>
|
|
22
|
-
leaf("│ ") +
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
leaf("│ ") +
|
|
27
|
+
cells
|
|
28
|
+
.map((c, i) => {
|
|
29
|
+
const cell = align[i] ? (c ?? "").padStart(widths[i]) : (c ?? "").padEnd(widths[i]);
|
|
30
|
+
return head ? bold(cell) : cell;
|
|
31
|
+
})
|
|
32
|
+
.join(leaf(" │ ")) +
|
|
33
|
+
leaf(" │");
|
|
34
|
+
return [
|
|
35
|
+
rule("┌", "┬", "┐"),
|
|
36
|
+
row(headers, true),
|
|
37
|
+
rule("├", "┼", "┤"),
|
|
38
|
+
...rows.map((r) => row(r)),
|
|
39
|
+
rule("└", "┴", "┘"),
|
|
40
|
+
].join("\n");
|
|
27
41
|
}
|
|
28
42
|
|
|
29
43
|
/** Every binding the compiled sprout will see, flattened to (name, type, detail) rows. */
|
|
30
44
|
function bindingRows(config: SproutboatConfig): string[][] {
|
|
31
45
|
const rows: string[][] = [];
|
|
32
|
-
for (const [name, value] of Object.entries(config.vars ?? {}))
|
|
46
|
+
for (const [name, value] of Object.entries(config.vars ?? {}))
|
|
47
|
+
rows.push([`env.${name}`, "var", JSON.stringify(value)]);
|
|
33
48
|
const list = (names: string[] | undefined, type: string, detail = "") => {
|
|
34
49
|
for (const name of names ?? []) rows.push([`env.${name}`, type, detail]);
|
|
35
50
|
};
|
|
36
51
|
const resourceList = (refs: Parameters<typeof resourceRefs>[0], type: string) => {
|
|
37
52
|
for (const ref of resourceRefs(refs)) {
|
|
38
|
-
rows.push([
|
|
53
|
+
rows.push([
|
|
54
|
+
`env.${ref.binding}`,
|
|
55
|
+
type,
|
|
56
|
+
ref.id ?? "deploy-scoped store (--no-provision) — drop the flag to auto-provision a persistent resource",
|
|
57
|
+
]);
|
|
39
58
|
}
|
|
40
59
|
};
|
|
41
60
|
resourceList(config.kv_namespaces, "kv");
|
|
@@ -44,7 +63,8 @@ function bindingRows(config: SproutboatConfig): string[][] {
|
|
|
44
63
|
resourceList(config.r2_buckets, "r2");
|
|
45
64
|
resourceList(config.queues, "queue");
|
|
46
65
|
list(config.analytics_engine_datasets, "analytics");
|
|
47
|
-
for (const [name, className] of Object.entries(config.durable_objects ?? {}))
|
|
66
|
+
for (const [name, className] of Object.entries(config.durable_objects ?? {}))
|
|
67
|
+
rows.push([`env.${name}`, "durable object", className]);
|
|
48
68
|
for (const host of config.outbound ?? []) rows.push([`fetch()`, "outbound", host]);
|
|
49
69
|
for (const cron of config.triggers?.crons ?? []) rows.push([`scheduled()`, "cron", cron]);
|
|
50
70
|
if (config.assets?.binding) rows.push([`env.${config.assets.binding}`, "assets", config.assets.directory ?? ""]);
|
|
@@ -72,14 +92,16 @@ export function printDeployReport(
|
|
|
72
92
|
console.log();
|
|
73
93
|
|
|
74
94
|
console.log(bold("Artifact"));
|
|
75
|
-
console.log(
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
[
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
95
|
+
console.log(
|
|
96
|
+
table(
|
|
97
|
+
["File", "Type", "Size"],
|
|
98
|
+
[
|
|
99
|
+
["sprout", manifest.runtime, bytes(sproutBin.length)],
|
|
100
|
+
["manifest.json", "json", bytes(manifestBytes)],
|
|
101
|
+
],
|
|
102
|
+
[false, false, true],
|
|
103
|
+
),
|
|
104
|
+
);
|
|
83
105
|
console.log(`Total upload: ${bold(bytes(total))} ${dim(`(sprout gzip: ${bytes(gz)})`)}`);
|
|
84
106
|
console.log();
|
|
85
107
|
|
package/src/source.ts
CHANGED
|
@@ -1,10 +1,25 @@
|
|
|
1
|
+
import { neutraliseExports } from "./wrap";
|
|
2
|
+
|
|
1
3
|
export type SourceValidation = { ok: true } | { ok: false; errors: string[] };
|
|
2
4
|
|
|
5
|
+
// Checked against the *bundled* module (#89), not the entry file: after
|
|
6
|
+
// bundling there are no imports left to reject, and a dependency reaching for a
|
|
7
|
+
// Node API has to fail exactly as hand-written code would. A bare specifier
|
|
8
|
+
// that resolves to nothing never gets this far — the bundler fails first.
|
|
3
9
|
const alwaysForbidden: Array<[RegExp, string]> = [
|
|
4
|
-
[/^\s*import\s/m, "imports
|
|
10
|
+
[/^\s*import\s/m, "an import survived bundling — only static imports can be resolved at build time"],
|
|
11
|
+
[/\bimport\s*\(/, "dynamic import() is not supported: nothing can resolve it at build time"],
|
|
5
12
|
[/\brequire\s*\(/, "CommonJS require is not supported"],
|
|
6
13
|
[/\b(WebSocket|XMLHttpRequest)\s*\(/, "WebSocket / XMLHttpRequest are not supported"],
|
|
7
14
|
[/\b(process|Bun|Deno|Buffer|node:)\b/, "Node, Bun, and Deno APIs are not supported"],
|
|
15
|
+
// Porffor alpha-4 compiles `new Proxy(...)` and then ignores the handler: a
|
|
16
|
+
// trapped property reads back as `undefined`, with no throw. Rejecting it
|
|
17
|
+
// here is the difference between a build error and a 502 nobody can explain.
|
|
18
|
+
// It is why itty-router and other Proxy-based routers do not work yet.
|
|
19
|
+
[
|
|
20
|
+
/\bnew\s+Proxy\s*\(|\bProxy\s*\.\s*revocable\s*\(/,
|
|
21
|
+
"Proxy is not supported by the compiler: its traps are silently ignored and the property reads back as undefined",
|
|
22
|
+
],
|
|
8
23
|
];
|
|
9
24
|
|
|
10
25
|
const fetchWithoutAllowlist: [RegExp, string] = [
|
|
@@ -17,7 +32,10 @@ export function validateHttpSyncSource(source: string, outboundAllowed = false):
|
|
|
17
32
|
// The default export must be an object literal with a `fetch` method. A module
|
|
18
33
|
// may also declare Durable Object classes / helpers before it, so this is not
|
|
19
34
|
// anchored to the start of the file.
|
|
20
|
-
|
|
35
|
+
// A hand-written file exports inline; a bundled one re-exports at the end.
|
|
36
|
+
// `neutraliseExports` is the same reader the compiler uses, so `check` cannot
|
|
37
|
+
// accept a module the build would then reject.
|
|
38
|
+
if (neutraliseExports(source) === null || !/\bfetch\s*\(/.test(source)) {
|
|
21
39
|
errors.push("handler must default-export an object with fetch(request)");
|
|
22
40
|
}
|
|
23
41
|
for (const [pattern, message] of alwaysForbidden) if (pattern.test(source)) errors.push(message);
|
package/src/style.ts
CHANGED
|
@@ -3,14 +3,17 @@
|
|
|
3
3
|
* or NO_COLOR is set (https://no-color.org), so piped/CI output stays plain.
|
|
4
4
|
*/
|
|
5
5
|
const enabled = Boolean(process.stdout.isTTY) && !process.env.NO_COLOR;
|
|
6
|
-
const paint =
|
|
6
|
+
const paint =
|
|
7
|
+
(code: string) =>
|
|
8
|
+
(text: string): string =>
|
|
9
|
+
enabled ? `\x1b[${code}m${text}\x1b[0m` : text;
|
|
7
10
|
|
|
8
|
-
export const leaf = paint("32");
|
|
9
|
-
export const sprout = paint("92");
|
|
10
|
-
export const dim = paint("2");
|
|
11
|
+
export const leaf = paint("32"); // green — headings, structure, the accent
|
|
12
|
+
export const sprout = paint("92"); // bright green — success
|
|
13
|
+
export const dim = paint("2"); // secondary detail
|
|
11
14
|
export const bold = paint("1");
|
|
12
|
-
export const amber = paint("33");
|
|
13
|
-
export const rose = paint("31");
|
|
15
|
+
export const amber = paint("33"); // warnings
|
|
16
|
+
export const rose = paint("31"); // errors
|
|
14
17
|
|
|
15
18
|
/** "✓ message" with a green tick. */
|
|
16
19
|
export const ok = (message: string): string => `${sprout("✓")} ${message}`;
|
package/src/surface.ts
CHANGED
|
@@ -9,7 +9,7 @@ export const CLI_NAME = "sproutboat";
|
|
|
9
9
|
export const TAGLINE = "Deploy JavaScript handlers as tiny native binaries on your own VPS.";
|
|
10
10
|
export const REPO_URL = "https://github.com/baronunread/sproutboat";
|
|
11
11
|
|
|
12
|
-
export type Group = "Develop" | "Ship" | "Configure" | "Account";
|
|
12
|
+
export type Group = "Develop" | "Ship" | "Storage" | "Configure" | "Account";
|
|
13
13
|
|
|
14
14
|
export type Command = {
|
|
15
15
|
name: string;
|
|
@@ -22,39 +22,162 @@ export type Command = {
|
|
|
22
22
|
summary: string;
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
+
/**
|
|
26
|
+
* The storage products. Each is its own command with the same five verbs over
|
|
27
|
+
* its own `/api/<segment>` collection.
|
|
28
|
+
*
|
|
29
|
+
* Wrangler nests two of its four (`kv namespace create`, `r2 bucket create`)
|
|
30
|
+
* and leaves `d1 create` and `queues create` flat. That nesting separates a
|
|
31
|
+
* container from its contents, which the verb already does — so ours are
|
|
32
|
+
* uniform, and contents take their own noun when they exist (`kv key get`).
|
|
33
|
+
*/
|
|
34
|
+
export type StorageProduct = {
|
|
35
|
+
/** Command name, URL segment, and the dashboard's product page. */
|
|
36
|
+
name: "kv" | "d1" | "r2" | "queues";
|
|
37
|
+
/** One of them, for buttons and messages: "namespace", "bucket". */
|
|
38
|
+
noun: string;
|
|
39
|
+
/** Many of them, for list output and empty states. */
|
|
40
|
+
plural: string;
|
|
41
|
+
emoji: string;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const STORAGE_PRODUCTS: readonly StorageProduct[] = [
|
|
45
|
+
{ name: "kv", noun: "namespace", plural: "KV namespaces", emoji: "🗄" },
|
|
46
|
+
{ name: "d1", noun: "database", plural: "D1 databases", emoji: "🛢" },
|
|
47
|
+
{ name: "r2", noun: "bucket", plural: "R2 buckets", emoji: "🪣" },
|
|
48
|
+
{ name: "queues", noun: "queue", plural: "queues", emoji: "📨" },
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
/** Every storage product answers to exactly these, in this order. */
|
|
52
|
+
export const STORAGE_VERBS = ["list", "create", "info", "rename", "delete"] as const;
|
|
53
|
+
|
|
54
|
+
const STORAGE_ARGS = "<list | create <name> | info <name> | rename <name> <new> | delete <name>>";
|
|
55
|
+
|
|
56
|
+
const storageCommands: readonly Command[] = STORAGE_PRODUCTS.map((product) => ({
|
|
57
|
+
name: product.name,
|
|
58
|
+
group: "Storage" as const,
|
|
59
|
+
emoji: product.emoji,
|
|
60
|
+
args: STORAGE_ARGS,
|
|
61
|
+
brief: `<${STORAGE_VERBS.join(" | ")}>`,
|
|
62
|
+
summary: `${product.plural[0].toUpperCase()}${product.plural.slice(1)}. \`create\` prints the id to bind from sproutboat.jsonc${product.name === "queues" ? "; consumers are not implemented yet" : ""}.`,
|
|
63
|
+
}));
|
|
64
|
+
|
|
25
65
|
export const COMMANDS: readonly Command[] = [
|
|
26
|
-
{
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
summary: "
|
|
32
|
-
|
|
33
|
-
{
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
{
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
args: "[
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
summary:
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
66
|
+
{
|
|
67
|
+
name: "init",
|
|
68
|
+
group: "Develop",
|
|
69
|
+
emoji: "🌱",
|
|
70
|
+
args: "[name]",
|
|
71
|
+
summary: "Scaffold sproutboat.jsonc + src/index.js in ./<name>.",
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
name: "check",
|
|
75
|
+
group: "Develop",
|
|
76
|
+
emoji: "🔍",
|
|
77
|
+
args: "[project-dir]",
|
|
78
|
+
summary: "Validate the config and entry point without building.",
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
name: "dev",
|
|
82
|
+
group: "Develop",
|
|
83
|
+
emoji: "⚡",
|
|
84
|
+
args: "[project-dir] [--port <n>] [--no-watch]",
|
|
85
|
+
brief: "[project-dir] [--port <n>]",
|
|
86
|
+
summary: "Run the project on this machine against a real broker, rebuilding on save.",
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
name: "build",
|
|
90
|
+
group: "Develop",
|
|
91
|
+
emoji: "🔨",
|
|
92
|
+
args: "[project-dir] [--target host]",
|
|
93
|
+
brief: "[project-dir]",
|
|
94
|
+
summary:
|
|
95
|
+
"Cross-compile the native-fetch sprout (Porffor + Zig). `--target host` builds for this machine instead, to run locally — not deployable.",
|
|
96
|
+
},
|
|
97
|
+
|
|
98
|
+
{
|
|
99
|
+
name: "deploy",
|
|
100
|
+
group: "Ship",
|
|
101
|
+
emoji: "🚀",
|
|
102
|
+
args: "[project-dir] [--dry-run] [--artifact <dir>] [--no-wait] [--no-provision]",
|
|
103
|
+
brief: "[project-dir] [--dry-run]",
|
|
104
|
+
summary:
|
|
105
|
+
"Build (unless --artifact), auto-provision id-less storage bindings and pin their ids into sproutboat.jsonc, print the report, upload, wait until the URL serves. The control plane skips an upload that matches the live artifact byte-for-byte. --dry-run stops before upload; --no-wait skips the health check; --no-provision leaves id-less bindings as ephemeral deploy-scoped stores.",
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: "versions",
|
|
109
|
+
group: "Ship",
|
|
110
|
+
emoji: "📜",
|
|
111
|
+
args: "<list | view <version-id>> [project-dir]",
|
|
112
|
+
brief: "<list | view>",
|
|
113
|
+
summary: "List the project's deployed versions, or show one version's artifact and bindings.",
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
name: "rollback",
|
|
117
|
+
group: "Ship",
|
|
118
|
+
emoji: "⏮",
|
|
119
|
+
args: "<version-id> [project-dir]",
|
|
120
|
+
brief: "<version-id>",
|
|
121
|
+
summary: "Re-activate a previous version.",
|
|
122
|
+
},
|
|
123
|
+
{
|
|
124
|
+
name: "tail",
|
|
125
|
+
group: "Ship",
|
|
126
|
+
emoji: "📡",
|
|
127
|
+
args: "[project-dir] [--sprout]",
|
|
128
|
+
summary: "Print recent request logs; --sprout prints the running sprout + broker stdout/stderr instead.",
|
|
129
|
+
},
|
|
130
|
+
|
|
131
|
+
...storageCommands,
|
|
132
|
+
|
|
133
|
+
{
|
|
134
|
+
name: "domains",
|
|
135
|
+
group: "Configure",
|
|
136
|
+
emoji: "🌐",
|
|
137
|
+
args: "<list | add <host> | verify <host> | delete <host>> [project-dir]",
|
|
138
|
+
brief: "<list | add | verify | delete>",
|
|
139
|
+
summary: "Attach a custom domain to the project (TXT-verified). No sub-command lists.",
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
name: "secrets",
|
|
143
|
+
group: "Configure",
|
|
144
|
+
emoji: "🔑",
|
|
145
|
+
args: "<list | put <NAME> [--value <value>] | delete <NAME>> [project-dir]",
|
|
146
|
+
brief: "<list | put | delete>",
|
|
147
|
+
summary:
|
|
148
|
+
"Manage encrypted project secrets (read as env.NAME). `put` reads the value from stdin unless --value is given, so it stays out of shell history; applies on next deploy.",
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
name: "delete",
|
|
152
|
+
group: "Configure",
|
|
153
|
+
emoji: "🗑",
|
|
154
|
+
args: "[project-dir] [--name <project>] --yes",
|
|
155
|
+
brief: "[project-dir] --yes",
|
|
156
|
+
summary: "Delete the project, every version, and its route.",
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
{
|
|
160
|
+
name: "login",
|
|
161
|
+
group: "Account",
|
|
162
|
+
emoji: "🔓",
|
|
163
|
+
args: "[--api-url <url>] [--token <token>]",
|
|
164
|
+
brief: "[--token <token>]",
|
|
165
|
+
summary: "Device-code browser flow, or store <token> for <url> directly.",
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
name: "logout",
|
|
169
|
+
group: "Account",
|
|
170
|
+
emoji: "🔒",
|
|
171
|
+
args: "[--api-url <url>]",
|
|
172
|
+
summary: "Forget the stored credential for the active endpoint, or for <url>.",
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: "whoami",
|
|
176
|
+
group: "Account",
|
|
177
|
+
emoji: "👤",
|
|
178
|
+
args: "",
|
|
179
|
+
summary: "Show the active endpoint and the account the stored token belongs to.",
|
|
180
|
+
},
|
|
58
181
|
];
|
|
59
182
|
|
|
60
183
|
export type EnvVar = { name: string; purpose: string };
|
|
@@ -63,21 +186,51 @@ export const ENV_VARS: readonly EnvVar[] = [
|
|
|
63
186
|
{ name: "SPROUTBOAT_API_URL", purpose: "Control-plane URL. Overrides the saved active endpoint." },
|
|
64
187
|
{ name: "SPROUTBOAT_TOKEN", purpose: "API token. Overrides the saved credential for the endpoint." },
|
|
65
188
|
{ name: "SPROUTBOAT_ZIG", purpose: "Path to a Zig binary to use instead of downloading the pinned one." },
|
|
66
|
-
{
|
|
189
|
+
{
|
|
190
|
+
name: "SPROUTBOAT_UWS_TARBALL",
|
|
191
|
+
purpose:
|
|
192
|
+
"Path to a prebuilt uWebSockets (x86_64-linux-musl) tarball to seed the Porffor cache with, instead of downloading it (removes the first-build git + make need).",
|
|
193
|
+
},
|
|
67
194
|
{ name: "SPROUTBOAT_COMPILE_TIMEOUT_MS", purpose: "Porffor compile timeout in ms (default 600000)." },
|
|
68
|
-
{
|
|
69
|
-
|
|
195
|
+
{
|
|
196
|
+
name: "SPROUTBOAT_VARS_JSON",
|
|
197
|
+
purpose:
|
|
198
|
+
"JSON object of baked `vars` (UPPER_SNAKE -> string), read by the wrapper when generating the sprout module.",
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
name: "SPROUTBOAT_BINDINGS_JSON",
|
|
202
|
+
purpose: "The artifact's bindings.json, read by the wrapper to emit the `__sbInstallBindings` line.",
|
|
203
|
+
},
|
|
70
204
|
{ name: "SPROUTBOAT_CONFIG_DIR", purpose: "Directory for credentials.json (default ~/.config/sproutboat)." },
|
|
71
|
-
{
|
|
72
|
-
|
|
205
|
+
{
|
|
206
|
+
name: "NO_COLOR",
|
|
207
|
+
purpose:
|
|
208
|
+
"When set, disables coloured terminal output (https://no-color.org). Output is also plain whenever stdout is not a TTY.",
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
name: "SPROUTBOAT_NO_UPDATE_CHECK",
|
|
212
|
+
purpose: "When set, skips the once-a-day npm check for a newer `sproutboat` release (also skipped when CI is set).",
|
|
213
|
+
},
|
|
73
214
|
{ name: "XDG_CONFIG_HOME", purpose: "Base for the default credentials dir when SPROUTBOAT_CONFIG_DIR is unset." },
|
|
74
215
|
{ name: "PORFFOR_VERSION", purpose: "Override the Porffor identity string recorded in the manifest." },
|
|
75
|
-
{
|
|
76
|
-
|
|
77
|
-
|
|
216
|
+
{
|
|
217
|
+
name: "SB_BROKER_PORT",
|
|
218
|
+
purpose:
|
|
219
|
+
"Loopback port of the binding broker, read by the compiled sprout at runtime (set by the control plane, or by `src/broker.ts` for local runs).",
|
|
220
|
+
},
|
|
221
|
+
{
|
|
222
|
+
name: "SB_BROKER_TOKEN",
|
|
223
|
+
purpose:
|
|
224
|
+
"Per-deployment auth token the sprout sends on every broker frame, and the broker sends back on scheduled/queue triggers (paired with SB_BROKER_PORT).",
|
|
225
|
+
},
|
|
226
|
+
{
|
|
227
|
+
name: "SB_SPROUT_URL",
|
|
228
|
+
purpose:
|
|
229
|
+
"http://127.0.0.1:<PORT> of the sprout; when set, `src/broker.ts` runs the cron scheduler and queue consumer and delivers triggers to it.",
|
|
230
|
+
},
|
|
78
231
|
];
|
|
79
232
|
|
|
80
|
-
const GROUP_ORDER: readonly Group[] = ["Develop", "Ship", "Configure", "Account"];
|
|
233
|
+
const GROUP_ORDER: readonly Group[] = ["Develop", "Ship", "Storage", "Configure", "Account"];
|
|
81
234
|
|
|
82
235
|
/** One-line usage string, e.g. for `usage()` and SURFACE.md. */
|
|
83
236
|
export function usageLine(): string {
|
package/src/toolchain.ts
CHANGED
|
@@ -13,14 +13,17 @@ import { dirname, resolve } from "node:path";
|
|
|
13
13
|
|
|
14
14
|
export const ZIG_VERSION = "0.16.0";
|
|
15
15
|
|
|
16
|
+
/** The `<arch>-<os>` platforms ziglang.org publishes a tarball for that we pin. */
|
|
17
|
+
type ZigPlatform = "x86_64-linux" | "aarch64-linux" | "x86_64-macos" | "aarch64-macos";
|
|
18
|
+
|
|
16
19
|
// sha256 of the official ziglang.org tarballs for ZIG_VERSION, keyed by
|
|
17
20
|
// `<arch>-<os>` (the download naming). Bump alongside ZIG_VERSION.
|
|
18
|
-
const ZIG_SHA256
|
|
21
|
+
const ZIG_SHA256 = {
|
|
19
22
|
"x86_64-linux": "70e49664a74374b48b51e6f3fdfbf437f6395d42509050588bd49abe52ba3d00",
|
|
20
23
|
"aarch64-linux": "ea4b09bfb22ec6f6c6ceac57ab63efb6b46e17ab08d21f69f3a48b38e1534f17",
|
|
21
24
|
"x86_64-macos": "0387557ed1877bc6a2e1802c8391953baddba76081876301c522f52977b52ba7",
|
|
22
25
|
"aarch64-macos": "b23d70deaa879b5c2d486ed3316f7eaa53e84acf6fc9cc747de152450d401489",
|
|
23
|
-
}
|
|
26
|
+
} satisfies Record<ZigPlatform, string>;
|
|
24
27
|
|
|
25
28
|
// Pinned Porffor identity — must match the `porffor` entry in package.json
|
|
26
29
|
// (`github:CanadaHonk/porffor#alpha-4`, commit a415d19). PORFFOR_VERSION overrides.
|
|
@@ -43,10 +46,13 @@ const UWS_COMMIT_FULL = "360c276d609d59af56ae6932adb95154ace9f15f";
|
|
|
43
46
|
// or the `uws-prebuild` workflow.
|
|
44
47
|
const UWS_TARBALL_SHA256 = "e83736f3f8cf9d56a1ebe6ea61625a7af12386763374d47c14cff472ada7484a";
|
|
45
48
|
|
|
46
|
-
function platformKey():
|
|
49
|
+
function platformKey(): ZigPlatform {
|
|
47
50
|
const arch = process.arch === "arm64" ? "aarch64" : process.arch === "x64" ? "x86_64" : null;
|
|
48
51
|
const os = process.platform === "linux" ? "linux" : process.platform === "darwin" ? "macos" : null;
|
|
49
|
-
if (!arch || !os)
|
|
52
|
+
if (!arch || !os)
|
|
53
|
+
throw new Error(
|
|
54
|
+
`no pinned Zig for ${process.platform}/${process.arch} — set SPROUTBOAT_ZIG to a zig ${ZIG_VERSION} binary`,
|
|
55
|
+
);
|
|
50
56
|
return `${arch}-${os}`;
|
|
51
57
|
}
|
|
52
58
|
|
|
@@ -85,7 +91,10 @@ export async function ensureZig(): Promise<string> {
|
|
|
85
91
|
}
|
|
86
92
|
|
|
87
93
|
// `tar -xJ` (xz) works on macOS bsdtar and GNU tar with xz on PATH.
|
|
88
|
-
const untar = Bun.spawn(["tar", "-xJf", archive, "-C", dir, "--strip-components=1"], {
|
|
94
|
+
const untar = Bun.spawn(["tar", "-xJf", archive, "-C", dir, "--strip-components=1"], {
|
|
95
|
+
stdout: "pipe",
|
|
96
|
+
stderr: "pipe",
|
|
97
|
+
});
|
|
89
98
|
const [code, err] = await Promise.all([untar.exited, new Response(untar.stderr).text()]);
|
|
90
99
|
if (code !== 0) throw new Error(`could not extract Zig (needs \`tar\` with xz support): ${err.trim()}`);
|
|
91
100
|
await rm(archive, { force: true });
|
|
@@ -141,12 +150,17 @@ export async function ensureUWebSockets(): Promise<void> {
|
|
|
141
150
|
if (!process.env.SPROUTBOAT_UWS_TARBALL) {
|
|
142
151
|
const actual = await sha256File(archive);
|
|
143
152
|
if (actual !== UWS_TARBALL_SHA256) {
|
|
144
|
-
throw new UwsUnavailableError(
|
|
153
|
+
throw new UwsUnavailableError(
|
|
154
|
+
`vendored uWebSockets sha256 mismatch\n expected ${UWS_TARBALL_SHA256}\n got ${actual}`,
|
|
155
|
+
);
|
|
145
156
|
}
|
|
146
157
|
}
|
|
147
158
|
|
|
148
159
|
await mkdir(dir, { recursive: true });
|
|
149
|
-
const untar = Bun.spawn(["tar", "-xJf", archive, "-C", dir, "--strip-components=1"], {
|
|
160
|
+
const untar = Bun.spawn(["tar", "-xJf", archive, "-C", dir, "--strip-components=1"], {
|
|
161
|
+
stdout: "pipe",
|
|
162
|
+
stderr: "pipe",
|
|
163
|
+
});
|
|
150
164
|
const [code, err] = await Promise.all([untar.exited, new Response(untar.stderr).text()]);
|
|
151
165
|
if (code !== 0) {
|
|
152
166
|
await rm(dir, { recursive: true, force: true });
|
package/src/update-check.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { readFile, writeFile } from "node:fs/promises";
|
|
8
8
|
import { resolve } from "node:path";
|
|
9
9
|
import { configDirectory } from "./credentials";
|
|
10
|
+
import { isSafeInteger, isString, jsonObject, parseJsonValue } from "./json";
|
|
10
11
|
import { dim } from "./style";
|
|
11
12
|
|
|
12
13
|
const CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
@@ -14,13 +15,25 @@ const REGISTRY = "https://registry.npmjs.org/sproutboat/latest";
|
|
|
14
15
|
|
|
15
16
|
type Cache = { checkedAt: number; latest: string };
|
|
16
17
|
|
|
18
|
+
/** Decode our own cache file, which a stale version or a partial write can corrupt. */
|
|
19
|
+
function parseCache(source: string): Cache | undefined {
|
|
20
|
+
const record = jsonObject(parseJsonValue(source));
|
|
21
|
+
return record && isSafeInteger(record.checkedAt) && isString(record.latest)
|
|
22
|
+
? { checkedAt: record.checkedAt, latest: record.latest }
|
|
23
|
+
: undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
17
26
|
function cachePath(): string {
|
|
18
27
|
return resolve(configDirectory(), "update-check.json");
|
|
19
28
|
}
|
|
20
29
|
|
|
21
30
|
/** Numeric x.y.z compare; a trailing `-tag` (prerelease) sorts before its release. */
|
|
22
31
|
function isNewer(latest: string, current: string): boolean {
|
|
23
|
-
const parts = (v: string) =>
|
|
32
|
+
const parts = (v: string) =>
|
|
33
|
+
v
|
|
34
|
+
.split("-")[0]
|
|
35
|
+
.split(".")
|
|
36
|
+
.map((n) => Number.parseInt(n, 10) || 0);
|
|
24
37
|
const [a, b] = [parts(latest), parts(current)];
|
|
25
38
|
for (let i = 0; i < 3; i++) {
|
|
26
39
|
if ((a[i] ?? 0) !== (b[i] ?? 0)) return (a[i] ?? 0) > (b[i] ?? 0);
|
|
@@ -30,18 +43,25 @@ function isNewer(latest: string, current: string): boolean {
|
|
|
30
43
|
|
|
31
44
|
async function latestVersion(): Promise<string | undefined> {
|
|
32
45
|
try {
|
|
33
|
-
const cached =
|
|
34
|
-
if (Date.now() - cached.checkedAt < CACHE_TTL_MS
|
|
35
|
-
} catch {
|
|
46
|
+
const cached = parseCache(await readFile(cachePath(), "utf8"));
|
|
47
|
+
if (cached && Date.now() - cached.checkedAt < CACHE_TTL_MS) return cached.latest;
|
|
48
|
+
} catch {
|
|
49
|
+
/* no cache yet, or unreadable — fetch below */
|
|
50
|
+
}
|
|
36
51
|
|
|
37
52
|
try {
|
|
38
|
-
const response = await fetch(REGISTRY, {
|
|
53
|
+
const response = await fetch(REGISTRY, {
|
|
54
|
+
signal: AbortSignal.timeout(1000),
|
|
55
|
+
headers: { accept: "application/json" },
|
|
56
|
+
});
|
|
39
57
|
if (!response.ok) return undefined;
|
|
40
|
-
const latest = ((await response.
|
|
41
|
-
if (
|
|
58
|
+
const latest = jsonObject(parseJsonValue(await response.text()))?.version;
|
|
59
|
+
if (!isString(latest)) return undefined;
|
|
42
60
|
await writeFile(cachePath(), JSON.stringify({ checkedAt: Date.now(), latest } satisfies Cache)).catch(() => {});
|
|
43
61
|
return latest;
|
|
44
|
-
} catch {
|
|
62
|
+
} catch {
|
|
63
|
+
/* offline / slow / DNS — skip silently */
|
|
64
|
+
}
|
|
45
65
|
return undefined;
|
|
46
66
|
}
|
|
47
67
|
|
|
@@ -50,6 +70,10 @@ export async function notifyIfOutdated(current: string): Promise<void> {
|
|
|
50
70
|
if (process.env.SPROUTBOAT_NO_UPDATE_CHECK || process.env.CI) return;
|
|
51
71
|
const latest = await latestVersion();
|
|
52
72
|
if (latest && isNewer(latest, current)) {
|
|
53
|
-
console.error(
|
|
73
|
+
console.error(
|
|
74
|
+
dim(
|
|
75
|
+
` update available: sproutboat ${current} → ${latest} · bump the dependency or run \`bunx sproutboat@latest\``,
|
|
76
|
+
),
|
|
77
|
+
);
|
|
54
78
|
}
|
|
55
79
|
}
|