sproutboat 0.8.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 +7 -4
- package/package.json +9 -5
- package/src/broker.ts +48 -5
- package/src/build.ts +8 -0
- package/src/compile.ts +42 -11
- package/src/dev.ts +2 -0
- package/src/main.ts +230 -2
- package/src/native-fetch-prelude.js +3 -3
- package/src/surface.ts +17 -3
- package/src/toolchain.ts +71 -0
- package/src/transport-broker.js +9 -0
- package/src/transport-embedded.js +4 -0
- package/src/types-gen.ts +90 -0
- package/types/sproutboat.d.ts +217 -0
package/src/main.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
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";
|
|
11
13
|
import { buildStandalone } from "./standalone-build";
|
|
@@ -231,6 +233,35 @@ async function check(directory?: string) {
|
|
|
231
233
|
console.log(ok(`check passed — ${project.config.name} (${project.config.main}, native-fetch)`));
|
|
232
234
|
}
|
|
233
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
|
+
|
|
234
265
|
async function build(directory?: string, target: "linux-x86_64" | "host" = "linux-x86_64") {
|
|
235
266
|
const project = await readProject(directory);
|
|
236
267
|
console.log(
|
|
@@ -287,6 +318,7 @@ async function dev(args: string[]) {
|
|
|
287
318
|
if (!Number.isSafeInteger(port) || port < 1 || port > 65_535)
|
|
288
319
|
usageError(`invalid --port: ${portArg}`, "dev [project-dir] [--port <n>] [--no-watch]");
|
|
289
320
|
const project = await readProject(directory);
|
|
321
|
+
await refreshTypes(project.directory, project.config);
|
|
290
322
|
console.log(dim(`Building ${project.config.name} for this machine (${hostTarget()})…`));
|
|
291
323
|
await runDev({
|
|
292
324
|
projectDir: project.directory,
|
|
@@ -410,6 +442,8 @@ async function deploy(args: string[]) {
|
|
|
410
442
|
projectName = built.project.config.name;
|
|
411
443
|
artifactDir = built.artifact.artifactDir;
|
|
412
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);
|
|
413
447
|
}
|
|
414
448
|
const manifest = Bun.file(resolve(artifactDir, "manifest.json"));
|
|
415
449
|
const sprout = Bun.file(resolve(artifactDir, "sprout"));
|
|
@@ -897,6 +931,194 @@ async function storage(key: string, args: string[]) {
|
|
|
897
931
|
console.log(ok(`deleted ${product.noun} ${name}`));
|
|
898
932
|
}
|
|
899
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
|
+
|
|
900
1122
|
async function deleteProject(args: string[]) {
|
|
901
1123
|
// `sproutboat delete [project-dir] [--name <project>] --yes` — flags in any order.
|
|
902
1124
|
const positional: string[] = [];
|
|
@@ -963,6 +1185,9 @@ switch (command) {
|
|
|
963
1185
|
case "check":
|
|
964
1186
|
await check(args[0]);
|
|
965
1187
|
break;
|
|
1188
|
+
case "types":
|
|
1189
|
+
await types(args[0]);
|
|
1190
|
+
break;
|
|
966
1191
|
case "dev":
|
|
967
1192
|
await dev(args);
|
|
968
1193
|
break;
|
|
@@ -998,6 +1223,9 @@ switch (command) {
|
|
|
998
1223
|
await secrets(args);
|
|
999
1224
|
break;
|
|
1000
1225
|
case "kv":
|
|
1226
|
+
if (["key", "bulk", "export"].includes(args[0] ?? "")) await kvContents(args);
|
|
1227
|
+
else await storage(command, args);
|
|
1228
|
+
break;
|
|
1001
1229
|
case "d1":
|
|
1002
1230
|
case "r2":
|
|
1003
1231
|
case "queues":
|
|
@@ -587,8 +587,8 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
587
587
|
|
|
588
588
|
// Static assets: env.<ASSETS>.fetch(request) -> broker `assets.get`. The edge
|
|
589
589
|
// already serves matching files directly; the sprout only calls this for paths
|
|
590
|
-
// it wants to own (SPA fallback, auth-gated files).
|
|
591
|
-
//
|
|
590
|
+
// it wants to own (SPA fallback, auth-gated files). The transport keeps the
|
|
591
|
+
// body byte-preserving for binary assets.
|
|
592
592
|
if (bindings.assets) {
|
|
593
593
|
target[bindings.assets] = {
|
|
594
594
|
fetch(input) {
|
|
@@ -598,7 +598,7 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
598
598
|
} catch {
|
|
599
599
|
/* use as-is */
|
|
600
600
|
}
|
|
601
|
-
const r =
|
|
601
|
+
const r = globalThis.__sbAssetsGet(path);
|
|
602
602
|
const headers = {};
|
|
603
603
|
if (r.type) headers["content-type"] = r.type;
|
|
604
604
|
if (r.found) headers["etag"] = '"' + r.hash + '"';
|
package/src/surface.ts
CHANGED
|
@@ -57,9 +57,9 @@ const storageCommands: readonly Command[] = STORAGE_PRODUCTS.map((product) => ({
|
|
|
57
57
|
name: product.name,
|
|
58
58
|
group: "Storage" as const,
|
|
59
59
|
emoji: product.emoji,
|
|
60
|
-
args: STORAGE_ARGS,
|
|
60
|
+
args: product.name === "kv" ? `${STORAGE_ARGS} | <key | bulk | export> ...` : STORAGE_ARGS,
|
|
61
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 deliver in batches with retries, and stop after 5 attempts" : ""}.`,
|
|
62
|
+
summary: `${product.plural[0].toUpperCase()}${product.plural.slice(1)}. \`create\` prints the id to bind from sproutboat.jsonc${product.name === "kv" ? "; key and bulk operations manage contents, and export writes a restorable JSON dump" : ""}${product.name === "queues" ? "; consumers deliver in batches with retries, and stop after 5 attempts" : ""}.`,
|
|
63
63
|
}));
|
|
64
64
|
|
|
65
65
|
export const COMMANDS: readonly Command[] = [
|
|
@@ -77,6 +77,14 @@ export const COMMANDS: readonly Command[] = [
|
|
|
77
77
|
args: "[project-dir]",
|
|
78
78
|
summary: "Validate the config and entry point without building.",
|
|
79
79
|
},
|
|
80
|
+
{
|
|
81
|
+
name: "types",
|
|
82
|
+
group: "Develop",
|
|
83
|
+
emoji: "🧾",
|
|
84
|
+
args: "[project-dir]",
|
|
85
|
+
summary:
|
|
86
|
+
"Write sproutboat-env.d.ts from the config, so an editor knows what `env` holds. `dev` and `deploy` refresh it when the config is newer.",
|
|
87
|
+
},
|
|
80
88
|
{
|
|
81
89
|
name: "dev",
|
|
82
90
|
group: "Develop",
|
|
@@ -189,8 +197,14 @@ export const ENV_VARS: readonly EnvVar[] = [
|
|
|
189
197
|
{
|
|
190
198
|
name: "SPROUTBOAT_UWS_TARBALL",
|
|
191
199
|
purpose:
|
|
192
|
-
"Path to a
|
|
200
|
+
"Path to a uWebSockets source tarball to seed the Porffor cache with, instead of the one vendored in the package. Both targets are seeded from it.",
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
name: "CC",
|
|
204
|
+
purpose:
|
|
205
|
+
"C compiler used to build uSockets for a host build (default `cc`). A host build needs one regardless: it is what Porffor compiles its own generated C with.",
|
|
193
206
|
},
|
|
207
|
+
{ name: "AR", purpose: "Archiver used to assemble uSockets.a for a host build (default `ar`)." },
|
|
194
208
|
{ name: "SPROUTBOAT_COMPILE_TIMEOUT_MS", purpose: "Porffor compile timeout in ms (default 600000)." },
|
|
195
209
|
{
|
|
196
210
|
name: "SPROUTBOAT_VARS_JSON",
|
package/src/toolchain.ts
CHANGED
|
@@ -156,6 +156,11 @@ export async function ensureUWebSockets(): Promise<void> {
|
|
|
156
156
|
}
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
await extractUws(archive, dir);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Unpack the vendored source tree into `dir`. */
|
|
163
|
+
async function extractUws(archive: string, dir: string): Promise<void> {
|
|
159
164
|
await mkdir(dir, { recursive: true });
|
|
160
165
|
const untar = Bun.spawn(["tar", "-xJf", archive, "-C", dir, "--strip-components=1"], {
|
|
161
166
|
stdout: "pipe",
|
|
@@ -168,6 +173,72 @@ export async function ensureUWebSockets(): Promise<void> {
|
|
|
168
173
|
}
|
|
169
174
|
}
|
|
170
175
|
|
|
176
|
+
/**
|
|
177
|
+
* The same seeding for a **host** build (`dev`, `build --target host`,
|
|
178
|
+
* `--standalone` on this machine).
|
|
179
|
+
*
|
|
180
|
+
* The vendored archive is a full checkout, so the sources are the same for
|
|
181
|
+
* every target; only `uSockets.a` is target-specific. Porffor would build that
|
|
182
|
+
* with `make -C uSockets`, which is where the `git` and `make` requirements on
|
|
183
|
+
* a first build came from. Compiling the thirteen C files directly removes
|
|
184
|
+
* both, and adds no new dependency: a host build already needs a C compiler,
|
|
185
|
+
* because that is what Porffor compiles its own generated C with.
|
|
186
|
+
*
|
|
187
|
+
* Flags mirror the uSockets makefile's default target with the WITH_* switches
|
|
188
|
+
* Porffor passes (all off): `-std=c11 -Isrc -DLIBUS_NO_SSL -flto -O3`, then
|
|
189
|
+
* `ar rvs`. Nothing here is a judgement call; it is that recipe.
|
|
190
|
+
*/
|
|
191
|
+
export async function ensureUWebSocketsHost(): Promise<void> {
|
|
192
|
+
const commit = uwsCommitFull();
|
|
193
|
+
const dir = resolve(homedir(), ".cache/porffor/deps", `uWebSockets-${commit}`);
|
|
194
|
+
const uSockets = resolve(dir, "uSockets");
|
|
195
|
+
const archivePath = resolve(uSockets, "uSockets.a");
|
|
196
|
+
if (existsSync(resolve(dir, "src/App.h")) && existsSync(archivePath)) return;
|
|
197
|
+
|
|
198
|
+
const vendored = process.env.SPROUTBOAT_UWS_TARBALL || uwsVendorArchive(commit.slice(0, 8));
|
|
199
|
+
if (!existsSync(vendored)) {
|
|
200
|
+
throw new UwsUnavailableError(`no vendored uWebSockets archive at ${vendored}`);
|
|
201
|
+
}
|
|
202
|
+
if (!process.env.SPROUTBOAT_UWS_TARBALL) {
|
|
203
|
+
const actual = await sha256File(vendored);
|
|
204
|
+
if (actual !== UWS_TARBALL_SHA256) {
|
|
205
|
+
throw new UwsUnavailableError(
|
|
206
|
+
`vendored uWebSockets sha256 mismatch\n expected ${UWS_TARBALL_SHA256}\n got ${actual}`,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (!existsSync(resolve(dir, "src/App.h"))) await extractUws(vendored, dir);
|
|
211
|
+
|
|
212
|
+
// The archive carries the musl-built uSockets.a. Linking that into a host
|
|
213
|
+
// binary fails in a way nobody would connect to this, so it goes first.
|
|
214
|
+
await rm(archivePath, { force: true });
|
|
215
|
+
|
|
216
|
+
const sources = ["src/*.c", "src/eventing/*.c", "src/crypto/*.c", "src/io_uring/*.c"];
|
|
217
|
+
const cc = process.env.CC || "cc";
|
|
218
|
+
const ar = process.env.AR || "ar";
|
|
219
|
+
const compile = Bun.spawn(["sh", "-c", `${cc} -std=c11 -Isrc -DLIBUS_NO_SSL -flto -O3 -c ${sources.join(" ")}`], {
|
|
220
|
+
cwd: uSockets,
|
|
221
|
+
stdout: "pipe",
|
|
222
|
+
stderr: "pipe",
|
|
223
|
+
});
|
|
224
|
+
const [ccCode, ccErr] = await Promise.all([compile.exited, new Response(compile.stderr).text()]);
|
|
225
|
+
if (ccCode !== 0) {
|
|
226
|
+
throw new UwsUnavailableError(
|
|
227
|
+
`could not compile uSockets with ${cc}: ${ccErr.trim().split("\n").slice(-3).join(" ")}`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const archiveStep = Bun.spawn(["sh", "-c", `${ar} rvs uSockets.a *.o`], {
|
|
232
|
+
cwd: uSockets,
|
|
233
|
+
stdout: "pipe",
|
|
234
|
+
stderr: "pipe",
|
|
235
|
+
});
|
|
236
|
+
const [arCode, arErr] = await Promise.all([archiveStep.exited, new Response(archiveStep.stderr).text()]);
|
|
237
|
+
if (arCode !== 0 || !existsSync(archivePath)) {
|
|
238
|
+
throw new UwsUnavailableError(`could not archive uSockets with ${ar}: ${arErr.trim()}`);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
171
242
|
/** Directory holding node_modules/porffor (walks up from this file). */
|
|
172
243
|
export function porfforRoot(start = import.meta.dir): string {
|
|
173
244
|
let dir = start;
|
package/src/transport-broker.js
CHANGED
|
@@ -288,3 +288,12 @@ globalThis.__sbR2Get = function (bucket, key) {
|
|
|
288
288
|
if (!reply.found) return { found: false };
|
|
289
289
|
return { found: true, object: reply.object, body: __sbTakeBin() };
|
|
290
290
|
};
|
|
291
|
+
|
|
292
|
+
/** Static asset metadata stays JSON; the response body uses the v1 byte tail. */
|
|
293
|
+
globalThis.__sbAssetsGet = function (path) {
|
|
294
|
+
const token = __sbEnv("SB_BROKER_TOKEN");
|
|
295
|
+
const reply = JSON.parse(__sbCallBin(JSON.stringify({ v: 1, token, op: "assets.get", path }), ""));
|
|
296
|
+
if (reply.ok === false) throw new Error("sproutboat assets.get: " + (reply.error || "failed"));
|
|
297
|
+
reply.body = __sbTakeBin();
|
|
298
|
+
return reply;
|
|
299
|
+
};
|
|
@@ -1413,6 +1413,10 @@ globalThis.__sbR2Get = function (bucket, key) {
|
|
|
1413
1413
|
return { found: true, object: meta.object, body: __sbR2GetRaw(__sbStore(), String(bucket), String(key)) };
|
|
1414
1414
|
};
|
|
1415
1415
|
|
|
1416
|
+
globalThis.__sbAssetsGet = function (path) {
|
|
1417
|
+
return __sbEmbeddedDispatch({ op: "assets.get", path });
|
|
1418
|
+
};
|
|
1419
|
+
|
|
1416
1420
|
/** The transport contract: one request string in, one reply string out. */
|
|
1417
1421
|
function __sbCall(reqJson) {
|
|
1418
1422
|
try {
|
package/src/types-gen.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `sproutboat types` — the project's `env`, as TypeScript.
|
|
3
|
+
*
|
|
4
|
+
* In Workers `env` is a parameter, so an editor can infer it from a signature.
|
|
5
|
+
* Here it is a global, which means nothing is inferable at all: `env.SESSIONS`
|
|
6
|
+
* is an undeclared identifier until this file exists. That makes generated
|
|
7
|
+
* types worth more here than they are there, not less.
|
|
8
|
+
*
|
|
9
|
+
* The output declares `env` from `sproutboat.jsonc` and references the ambient
|
|
10
|
+
* types shipped in `types/sproutboat.d.ts`, so binding *shapes* version with
|
|
11
|
+
* the CLI and binding *names* version with the project.
|
|
12
|
+
*
|
|
13
|
+
* Names need no quoting: `parseConfig` already holds every binding and var to
|
|
14
|
+
* /^[A-Z][A-Z0-9_]*$/, so all of them are plain identifiers.
|
|
15
|
+
*/
|
|
16
|
+
import { resourceRefs, type SproutboatConfig } from "./config";
|
|
17
|
+
|
|
18
|
+
/** The file the generator writes, relative to the project directory. */
|
|
19
|
+
export const TYPES_FILE = "sproutboat-env.d.ts";
|
|
20
|
+
|
|
21
|
+
const HEADER = `// Generated by \`sproutboat types\`. Do not edit.
|
|
22
|
+
//
|
|
23
|
+
// Regenerated whenever sproutboat.jsonc is newer than this file, so a binding
|
|
24
|
+
// added to the config shows up in the editor without asking. Commit it: it is
|
|
25
|
+
// part of the project's type surface, like a schema.
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
type Entry = { name: string; type: string; note?: string };
|
|
29
|
+
|
|
30
|
+
function entries(config: SproutboatConfig): Entry[] {
|
|
31
|
+
const out: Entry[] = [];
|
|
32
|
+
|
|
33
|
+
for (const [name, value] of Object.entries(config.vars ?? {})) {
|
|
34
|
+
out.push({ name, type: "string", note: `vars: ${JSON.stringify(value)}` });
|
|
35
|
+
}
|
|
36
|
+
for (const name of config.secrets ?? []) {
|
|
37
|
+
out.push({ name, type: "string", note: "secret, set with `sproutboat secrets set`" });
|
|
38
|
+
}
|
|
39
|
+
for (const { binding } of resourceRefs(config.kv_namespaces)) out.push({ name: binding, type: "KVNamespace" });
|
|
40
|
+
for (const { binding } of resourceRefs(config.d1_databases)) out.push({ name: binding, type: "D1Database" });
|
|
41
|
+
for (const { binding } of resourceRefs(config.r2_buckets)) out.push({ name: binding, type: "R2Bucket" });
|
|
42
|
+
for (const { binding } of resourceRefs(config.queues)) out.push({ name: binding, type: "Queue" });
|
|
43
|
+
for (const name of config.analytics_engine_datasets ?? []) {
|
|
44
|
+
out.push({ name, type: "AnalyticsEngineDataset" });
|
|
45
|
+
}
|
|
46
|
+
for (const [binding, className] of Object.entries(config.durable_objects ?? {})) {
|
|
47
|
+
out.push({ name: binding, type: "DurableObjectNamespace", note: `class ${className}` });
|
|
48
|
+
}
|
|
49
|
+
for (const service of config.services ?? []) {
|
|
50
|
+
out.push({ name: service.binding, type: "Fetcher", note: `service binding: ${service.service}` });
|
|
51
|
+
}
|
|
52
|
+
if (config.assets?.binding) {
|
|
53
|
+
out.push({ name: config.assets.binding, type: "Fetcher", note: `static assets: ${config.assets.directory}` });
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The contents of `sproutboat-env.d.ts` for `config`.
|
|
60
|
+
*
|
|
61
|
+
* Pure, so the test can assert on the text and `dev` can compare it against
|
|
62
|
+
* what is already on disk without writing.
|
|
63
|
+
*/
|
|
64
|
+
export function generateTypes(config: SproutboatConfig): string {
|
|
65
|
+
const rows = entries(config);
|
|
66
|
+
const body = rows.length
|
|
67
|
+
? rows
|
|
68
|
+
.map((entry) => ` ${entry.note ? `/** ${entry.note} */\n ` : ""}${entry.name}: ${entry.type};`)
|
|
69
|
+
.join("\n")
|
|
70
|
+
: " // No bindings declared in sproutboat.jsonc yet.";
|
|
71
|
+
|
|
72
|
+
return `${HEADER}
|
|
73
|
+
/// <reference types="sproutboat/types" />
|
|
74
|
+
|
|
75
|
+
declare global {
|
|
76
|
+
/**
|
|
77
|
+
* Bindings from sproutboat.jsonc, for project "${config.name}".
|
|
78
|
+
*
|
|
79
|
+
* A global, not a parameter: the handler signature is \`fetch(request)\`.
|
|
80
|
+
* Every call on these is synchronous, so \`await\` is allowed and does
|
|
81
|
+
* nothing.
|
|
82
|
+
*/
|
|
83
|
+
const env: {
|
|
84
|
+
${body}
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export {};
|
|
89
|
+
`;
|
|
90
|
+
}
|