sproutboat 0.7.0 → 0.8.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/SURFACE.md +10 -3
- package/package.json +4 -2
- package/src/bearssl.ts +180 -0
- package/src/broker.ts +372 -14
- package/src/build.ts +53 -1
- package/src/compile.ts +52 -3
- package/src/config.ts +25 -0
- package/src/main.ts +30 -4
- package/src/native-fetch-prelude.js +127 -168
- package/src/patch-porffor.ts +104 -6
- package/src/sqlite.ts +99 -0
- package/src/standalone-build.ts +64 -0
- package/src/surface.ts +37 -2
- package/src/transport-broker.js +290 -0
- package/src/transport-embedded.js +1567 -0
- package/src/wrap.ts +46 -2
- package/vendor/sqlite-amalgamation-3500400/shell.c +34019 -0
- package/vendor/sqlite-amalgamation-3500400/sqlite3.c +262899 -0
- package/vendor/sqlite-amalgamation-3500400/sqlite3.h +13775 -0
- package/vendor/sqlite-amalgamation-3500400/sqlite3ext.h +723 -0
- package/vendor/sqlite-amalgamation.zip +0 -0
package/src/compile.ts
CHANGED
|
@@ -13,7 +13,16 @@ 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";
|
|
16
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
EMPTY_BINDINGS,
|
|
18
|
+
preludePath,
|
|
19
|
+
transportPath,
|
|
20
|
+
wrapNativeFetchHandler,
|
|
21
|
+
TRANSPORT_MARKER,
|
|
22
|
+
type Bindings,
|
|
23
|
+
type Transport,
|
|
24
|
+
} from "./wrap";
|
|
25
|
+
export type { Transport } from "./wrap";
|
|
17
26
|
|
|
18
27
|
export {
|
|
19
28
|
BASELINE_COMPATIBILITY_DATE,
|
|
@@ -35,6 +44,16 @@ export type CompileInput = {
|
|
|
35
44
|
/** The project's `compatibility_date`, baked in so the runtime can gate a
|
|
36
45
|
* behaviour change on it. Defaults to the baseline when absent. */
|
|
37
46
|
compatibilityDate?: string;
|
|
47
|
+
/** #15 — which `__sbCall` to compile in. Defaults to the broker transport. */
|
|
48
|
+
transport?: Transport;
|
|
49
|
+
/** #15 — the project name, baked so an embedded build can default its data dir. */
|
|
50
|
+
appName?: string;
|
|
51
|
+
/** #15 — assets baked into the module (embedded builds have no files on disk). */
|
|
52
|
+
assets?: { manifest: unknown; files: Record<string, string> };
|
|
53
|
+
/** #15 — objects to add to the native-fetch link line (SQLite, BearSSL). */
|
|
54
|
+
extraLink?: string[];
|
|
55
|
+
/** #15 — flags for the compile step, so inline C can include BearSSL's header. */
|
|
56
|
+
extraCflags?: string[];
|
|
38
57
|
/** Cross-compiler for `linux-x86_64`. Not needed, and not used, for `host`. */
|
|
39
58
|
zigBin?: string;
|
|
40
59
|
/**
|
|
@@ -46,6 +65,29 @@ export type CompileInput = {
|
|
|
46
65
|
target?: "linux-x86_64" | "host";
|
|
47
66
|
};
|
|
48
67
|
|
|
68
|
+
/**
|
|
69
|
+
* The prelude with a transport spliced in — the exact text the compiler sees.
|
|
70
|
+
*
|
|
71
|
+
* Exported because more than one caller composes a sprout: the build, and the
|
|
72
|
+
* kitchen-sink harness that compiles one itself. Reading
|
|
73
|
+
* `native-fetch-prelude.js` alone yields a module with no `__sbCall` at all.
|
|
74
|
+
*/
|
|
75
|
+
export async function loadPrelude(transport: Transport = "broker"): Promise<string> {
|
|
76
|
+
const [core, chosen] = await Promise.all([readFile(preludePath, "utf8"), readFile(transportPath(transport), "utf8")]);
|
|
77
|
+
if (!core.includes(TRANSPORT_MARKER)) {
|
|
78
|
+
throw new Error("prelude is missing its transport marker — src/native-fetch-prelude.js changed shape");
|
|
79
|
+
}
|
|
80
|
+
return core.replace(TRANSPORT_MARKER, chosen);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Child env for the Porffor run. `SB_EXTRA_LINK` is read by the patched link
|
|
84
|
+
* step (#15) and is absent entirely for a normal build. */
|
|
85
|
+
function compileEnv(path: string, extraLink?: string[], extraCflags?: string[]) {
|
|
86
|
+
const link = extraLink && extraLink.length > 0 ? extraLink.join(" ") : undefined;
|
|
87
|
+
const cflags = extraCflags && extraCflags.length > 0 ? extraCflags.join(" ") : undefined;
|
|
88
|
+
return { ...process.env, PATH: path, SB_EXTRA_LINK: link, SB_EXTRA_CFLAGS: cflags };
|
|
89
|
+
}
|
|
90
|
+
|
|
49
91
|
/** Compile `sourcePath` to a native binary at `outPath` (mode 0555). */
|
|
50
92
|
export async function compileSprout(input: CompileInput): Promise<void> {
|
|
51
93
|
await ensurePorfforPatched();
|
|
@@ -83,7 +125,7 @@ export async function compileSprout(input: CompileInput): Promise<void> {
|
|
|
83
125
|
const generatedPath = resolve(outDir, "sprout.generated.js");
|
|
84
126
|
const [source, prelude] = await Promise.all([
|
|
85
127
|
input.source === undefined ? readFile(input.sourcePath, "utf8") : Promise.resolve(input.source),
|
|
86
|
-
|
|
128
|
+
loadPrelude(input.transport ?? "broker"),
|
|
87
129
|
]);
|
|
88
130
|
await writeFile(
|
|
89
131
|
generatedPath,
|
|
@@ -94,6 +136,8 @@ export async function compileSprout(input: CompileInput): Promise<void> {
|
|
|
94
136
|
input.bindings ?? EMPTY_BINDINGS,
|
|
95
137
|
undefined,
|
|
96
138
|
input.compatibilityDate,
|
|
139
|
+
input.appName,
|
|
140
|
+
input.assets,
|
|
97
141
|
),
|
|
98
142
|
);
|
|
99
143
|
|
|
@@ -113,7 +157,12 @@ export async function compileSprout(input: CompileInput): Promise<void> {
|
|
|
113
157
|
const crossFlags = input.target === "host" ? [] : ["--musl"];
|
|
114
158
|
const child = Bun.spawn(
|
|
115
159
|
[process.execPath, launcher, "native", generatedPath, "-o", input.outPath, ...crossFlags, "-s"],
|
|
116
|
-
{
|
|
160
|
+
{
|
|
161
|
+
cwd: outDir,
|
|
162
|
+
stdout: "pipe",
|
|
163
|
+
stderr: "pipe",
|
|
164
|
+
env: compileEnv(path, input.extraLink, input.extraCflags),
|
|
165
|
+
},
|
|
117
166
|
);
|
|
118
167
|
let timedOut = false;
|
|
119
168
|
const timer = setTimeout(() => {
|
package/src/config.ts
CHANGED
|
@@ -58,6 +58,9 @@ export type SproutboatConfig = {
|
|
|
58
58
|
analytics_engine_datasets?: string[];
|
|
59
59
|
/** Durable Object bindings: `{ BINDING_NAME: "ClassName" }`. The class is defined in the handler module. */
|
|
60
60
|
durable_objects?: Record<string, string>;
|
|
61
|
+
/** #48 — worker-to-worker calls: `env.<BINDING>.fetch(request)` reaches another
|
|
62
|
+
* project on this control plane, resolved to its hostname at activation. */
|
|
63
|
+
services?: Array<{ binding: string; service: string }>;
|
|
61
64
|
/** Scheduled triggers, e.g. `{ "crons": ["0 3 * * *"] }` — a `scheduled(event)` handler runs on each tick. */
|
|
62
65
|
triggers?: { crons?: string[] };
|
|
63
66
|
/** Static assets: a directory served edge-first (like Cloudflare), optionally bound as `env.<BINDING>.fetch(request)`. */
|
|
@@ -114,6 +117,7 @@ function validateConfig(value: ConfigInput): ConfigValidation {
|
|
|
114
117
|
"queues",
|
|
115
118
|
"analytics_engine_datasets",
|
|
116
119
|
"durable_objects",
|
|
120
|
+
"services",
|
|
117
121
|
"triggers",
|
|
118
122
|
"assets",
|
|
119
123
|
]);
|
|
@@ -233,6 +237,25 @@ function validateConfig(value: ConfigInput): ConfigValidation {
|
|
|
233
237
|
}
|
|
234
238
|
}
|
|
235
239
|
|
|
240
|
+
let services: Array<{ binding: string; service: string }> | undefined;
|
|
241
|
+
if (value.services !== undefined) {
|
|
242
|
+
if (!Array.isArray(value.services)) {
|
|
243
|
+
errors.push('services must be an array of { binding: "NAME", service: "project-name" }');
|
|
244
|
+
} else {
|
|
245
|
+
services = [];
|
|
246
|
+
for (const entry of value.services) {
|
|
247
|
+
const row = isRecord(entry) ? entry : null;
|
|
248
|
+
const binding = row && isString(row.binding) ? row.binding : "";
|
|
249
|
+
const service = row && isString(row.service) ? row.service : "";
|
|
250
|
+
if (!bindingName.test(binding) || !isProjectSlug(service)) {
|
|
251
|
+
errors.push('services entries must be { binding: "UPPER_SNAKE", service: "project-name" }');
|
|
252
|
+
} else if (services.some((s) => s.binding === binding)) {
|
|
253
|
+
errors.push(`services: duplicate binding ${binding}`);
|
|
254
|
+
} else services.push({ binding, service });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
236
259
|
let triggers: { crons?: string[] } | undefined;
|
|
237
260
|
if (value.triggers !== undefined) {
|
|
238
261
|
if (!isRecord(value.triggers)) {
|
|
@@ -301,6 +324,7 @@ function validateConfig(value: ConfigInput): ConfigValidation {
|
|
|
301
324
|
...resourceNames(queues),
|
|
302
325
|
...(analytics_engine_datasets ?? []),
|
|
303
326
|
...Object.keys(durable_objects ?? {}),
|
|
327
|
+
...(services ?? []).map((entry) => entry.binding),
|
|
304
328
|
...Object.keys(vars ?? {}),
|
|
305
329
|
...(assets?.binding ? [assets.binding] : []),
|
|
306
330
|
];
|
|
@@ -319,6 +343,7 @@ function validateConfig(value: ConfigInput): ConfigValidation {
|
|
|
319
343
|
if ("queues" in value) config.queues = queues;
|
|
320
344
|
if ("analytics_engine_datasets" in value) config.analytics_engine_datasets = analytics_engine_datasets;
|
|
321
345
|
if ("durable_objects" in value) config.durable_objects = durable_objects;
|
|
346
|
+
if ("services" in value) config.services = services;
|
|
322
347
|
if ("triggers" in value) config.triggers = triggers;
|
|
323
348
|
if ("assets" in value) config.assets = assets;
|
|
324
349
|
return { ok: true, value: config };
|
package/src/main.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { validateHttpSyncSource } from "./source";
|
|
|
8
8
|
import { buildArtifact } from "./build";
|
|
9
9
|
import { bundleHandler, BundleError, type BundleResult } from "./bundle";
|
|
10
10
|
import { runDev } from "./dev";
|
|
11
|
+
import { buildStandalone } from "./standalone-build";
|
|
11
12
|
import { hostTarget, validateManifest, type ArtifactManifest } from "./manifest";
|
|
12
13
|
import { CLI_VERSION, printDeployReport } from "./report";
|
|
13
14
|
import { activeApiUrl, forgetToken, savedToken, saveToken } from "./credentials";
|
|
@@ -251,6 +252,32 @@ async function build(directory?: string, target: "linux-x86_64" | "host" = "linu
|
|
|
251
252
|
return { project, artifact };
|
|
252
253
|
}
|
|
253
254
|
|
|
255
|
+
/**
|
|
256
|
+
* #15 — one executable carrying the sprout, its assets and its bindings.
|
|
257
|
+
*
|
|
258
|
+
* Defaults to a host build: the point is a binary you can run right here. Pass
|
|
259
|
+
* `--target linux-x86_64` for a box, but note the Bun launcher is compiled for
|
|
260
|
+
* *this* machine either way in phase 0 — cross-compiling both halves comes with
|
|
261
|
+
* the embedded backend, which removes the launcher entirely.
|
|
262
|
+
*/
|
|
263
|
+
async function buildStandaloneBinary(directory: string | undefined, target: "linux-x86_64" | "host") {
|
|
264
|
+
const project = await readProject(directory);
|
|
265
|
+
console.log(dim(`Building a standalone ${project.config.name} (${target})…`));
|
|
266
|
+
try {
|
|
267
|
+
const result = await buildStandalone({
|
|
268
|
+
projectDir: project.directory,
|
|
269
|
+
config: project.config,
|
|
270
|
+
sourcePath: project.sourcePath,
|
|
271
|
+
source: project.bundle.code,
|
|
272
|
+
target,
|
|
273
|
+
});
|
|
274
|
+
console.log(ok(`built ${result.outPath} (${(result.bytes / 1_000_000).toFixed(1)} MB)`));
|
|
275
|
+
console.log(dim(` run it: ${result.outPath} · state lands in ./${project.config.name}.data`));
|
|
276
|
+
} catch (error) {
|
|
277
|
+
fail(error instanceof Error ? error.message : String(error));
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
254
281
|
/** #62 — build for this machine, run it against a real broker, rebuild on save. */
|
|
255
282
|
async function dev(args: string[]) {
|
|
256
283
|
const directory = args.find((arg) => !arg.startsWith("--") && !/^\d+$/.test(arg));
|
|
@@ -941,10 +968,9 @@ switch (command) {
|
|
|
941
968
|
break;
|
|
942
969
|
case "build": {
|
|
943
970
|
const hostBuild = args.includes("--target") && args[args.indexOf("--target") + 1] === "host";
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
);
|
|
971
|
+
const directory = args.find((arg) => !arg.startsWith("--") && arg !== "host");
|
|
972
|
+
if (args.includes("--standalone")) await buildStandaloneBinary(directory, hostBuild ? "host" : "linux-x86_64");
|
|
973
|
+
else await build(directory, hostBuild ? "host" : "linux-x86_64");
|
|
948
974
|
break;
|
|
949
975
|
}
|
|
950
976
|
case "login":
|
|
@@ -283,16 +283,10 @@ if (globalThis.crypto.randomUUID == null) {
|
|
|
283
283
|
// sprout event-loop turn per request, so a blocking roundtrip is acceptable).
|
|
284
284
|
// Wire frame:
|
|
285
285
|
// [u32 LE len][ <token> "\n" <json> ] reply: [u32 LE len][ <json> ]
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
// ponytail: text values only; still AF_INET loopback, not AF_UNIX. A failed
|
|
291
|
-
// exchange reconnects and resends once — a broker crash between "request applied"
|
|
292
|
-
// and "reply read" can double-apply a non-idempotent op (queue.send, INSERT);
|
|
293
|
-
// the old fresh-connection-per-call path just failed the call there instead.
|
|
294
|
-
// Binary values + AF_UNIX = v2.
|
|
295
|
-
|
|
286
|
+
// Shared C preamble: the headers, the two Porffor marshalling helpers every
|
|
287
|
+
// inline-C block uses, and the CSPRNG. Lives here rather than in a transport so
|
|
288
|
+
// both transports — and __sbRandomBytes / __sbEnv below — compile against the
|
|
289
|
+
// same declarations.
|
|
296
290
|
// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
|
|
297
291
|
Porffor.c`
|
|
298
292
|
#include <sys/socket.h>
|
|
@@ -330,118 +324,9 @@ static int sb_os_random(unsigned char* buf, size_t n) {
|
|
|
330
324
|
return 0;
|
|
331
325
|
}
|
|
332
326
|
|
|
333
|
-
static int sb_io_all(int fd, unsigned char* buf, size_t len, int writing) {
|
|
334
|
-
size_t done = 0;
|
|
335
|
-
while (done < len) {
|
|
336
|
-
long n = writing ? write(fd, buf + done, len - done) : read(fd, buf + done, len - done);
|
|
337
|
-
if (n <= 0) {
|
|
338
|
-
if (n < 0 && errno == EINTR) continue;
|
|
339
|
-
return -1;
|
|
340
|
-
}
|
|
341
|
-
done += (size_t)n;
|
|
342
|
-
}
|
|
343
|
-
return 0;
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
// One long-lived loopback connection to the broker, reused across every binding
|
|
347
|
-
// call. The broker frames each request/reply independently and keeps the socket
|
|
348
|
-
// open, so the steady-state per-call cost is just write + read — no socket(),
|
|
349
|
-
// connect() handshake or close() each time. -1 = not connected.
|
|
350
|
-
static int sb_broker_fd = -1;
|
|
351
|
-
|
|
352
|
-
static int sb_broker_connect(void) {
|
|
353
|
-
const char* port_s = getenv("SB_BROKER_PORT");
|
|
354
|
-
if (!port_s) return -10;
|
|
355
|
-
signal(SIGPIPE, SIG_IGN); // a dead broker must yield EPIPE, not kill the sprout
|
|
356
|
-
int fd = socket(AF_INET, SOCK_STREAM, 0);
|
|
357
|
-
if (fd < 0) return -1;
|
|
358
|
-
int one = 1;
|
|
359
|
-
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
|
|
360
|
-
struct sockaddr_in addr;
|
|
361
|
-
memset(&addr, 0, sizeof(addr));
|
|
362
|
-
addr.sin_family = AF_INET;
|
|
363
|
-
addr.sin_port = htons((unsigned short)atoi(port_s));
|
|
364
|
-
addr.sin_addr.s_addr = htonl(0x7f000001u); // 127.0.0.1
|
|
365
|
-
if (connect(fd, (struct sockaddr*)&addr, sizeof(addr)) != 0) { close(fd); return -2; }
|
|
366
|
-
sb_broker_fd = fd;
|
|
367
|
-
return 0;
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
// Send one framed request, read one framed reply, on the persistent fd.
|
|
371
|
-
static int sb_broker_exchange(const char* req, size_t req_len, char** resp_out, size_t* resp_len_out) {
|
|
372
|
-
const char* tok = getenv("SB_BROKER_TOKEN");
|
|
373
|
-
size_t tok_len = tok ? strlen(tok) : 0;
|
|
374
|
-
|
|
375
|
-
// frame body: token "\n" json
|
|
376
|
-
size_t body_len = tok_len + 1 + req_len;
|
|
377
|
-
unsigned char* frame = (unsigned char*)malloc(4 + body_len);
|
|
378
|
-
if (!frame) return -5;
|
|
379
|
-
frame[0] = (unsigned char)(body_len & 0xff);
|
|
380
|
-
frame[1] = (unsigned char)((body_len >> 8) & 0xff);
|
|
381
|
-
frame[2] = (unsigned char)((body_len >> 16) & 0xff);
|
|
382
|
-
frame[3] = (unsigned char)((body_len >> 24) & 0xff);
|
|
383
|
-
if (tok_len) memcpy(frame + 4, tok, tok_len);
|
|
384
|
-
frame[4 + tok_len] = '\n';
|
|
385
|
-
if (req_len) memcpy(frame + 4 + tok_len + 1, req, req_len);
|
|
386
|
-
int wr = sb_io_all(sb_broker_fd, frame, 4 + body_len, 1);
|
|
387
|
-
free(frame);
|
|
388
|
-
if (wr != 0) return -3;
|
|
389
|
-
|
|
390
|
-
unsigned char rhdr[4];
|
|
391
|
-
if (sb_io_all(sb_broker_fd, rhdr, 4, 0) != 0) return -4;
|
|
392
|
-
size_t rlen = (size_t)rhdr[0] | ((size_t)rhdr[1] << 8) | ((size_t)rhdr[2] << 16) | ((size_t)rhdr[3] << 24);
|
|
393
|
-
|
|
394
|
-
char* buf = (char*)malloc(rlen ? rlen : 1);
|
|
395
|
-
if (!buf) return -5;
|
|
396
|
-
if (rlen && sb_io_all(sb_broker_fd, (unsigned char*)buf, rlen, 0) != 0) { free(buf); return -6; }
|
|
397
|
-
|
|
398
|
-
*resp_out = buf;
|
|
399
|
-
*resp_len_out = rlen;
|
|
400
|
-
return 0;
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
static int sb_broker_roundtrip(const char* req, size_t req_len, char** resp_out, size_t* resp_len_out) {
|
|
404
|
-
*resp_out = NULL;
|
|
405
|
-
*resp_len_out = 0;
|
|
406
|
-
// Two tries: a broker restart (or an idle-closed socket) invalidates the fd,
|
|
407
|
-
// so a failed exchange drops the connection and reconnects once before failing.
|
|
408
|
-
for (int attempt = 0; attempt < 2; attempt++) {
|
|
409
|
-
if (sb_broker_fd < 0) {
|
|
410
|
-
int rc = sb_broker_connect();
|
|
411
|
-
if (rc != 0) return rc;
|
|
412
|
-
}
|
|
413
|
-
int rc = sb_broker_exchange(req, req_len, resp_out, resp_len_out);
|
|
414
|
-
if (rc == 0) return 0;
|
|
415
|
-
close(sb_broker_fd);
|
|
416
|
-
sb_broker_fd = -1;
|
|
417
|
-
}
|
|
418
|
-
return -3;
|
|
419
|
-
}
|
|
420
327
|
`;
|
|
421
328
|
|
|
422
|
-
//
|
|
423
|
-
// generated C names it directly in the RawC block below.
|
|
424
|
-
// oxlint-disable-next-line no-unused-vars -- `reqJson` is read inside the RawC block below, not by JS.
|
|
425
|
-
function __sbCall(reqJson) {
|
|
426
|
-
let res = "";
|
|
427
|
-
// oxlint-disable-next-line no-unused-expressions -- Porffor.c`...` is inline C the compiler consumes, not a JS expression.
|
|
428
|
-
Porffor.c`
|
|
429
|
-
const char* __req; size_t __reqlen; char* __reqowned = 0;
|
|
430
|
-
porf_native_fetch_read_value(reqJson, &__req, &__reqlen, &__reqowned);
|
|
431
|
-
char* __resp = 0; size_t __resplen = 0;
|
|
432
|
-
int __rc = sb_broker_roundtrip(__req, __reqlen, &__resp, &__resplen);
|
|
433
|
-
if (__reqowned) free(__reqowned);
|
|
434
|
-
if (__rc == 0) {
|
|
435
|
-
res = porf_box((f64)porf_native_fetch_alloc_bytestring(__resp, __resplen), 195);
|
|
436
|
-
free(__resp);
|
|
437
|
-
} else {
|
|
438
|
-
char __e[40];
|
|
439
|
-
int __n = snprintf(__e, sizeof(__e), "{\"ok\":false,\"error\":\"broker rc %d\"}", __rc);
|
|
440
|
-
res = porf_box((f64)porf_native_fetch_alloc_bytestring(__e, (size_t)__n), 195);
|
|
441
|
-
}
|
|
442
|
-
`;
|
|
443
|
-
return res;
|
|
444
|
-
}
|
|
329
|
+
// TRANSPORT: wrap.ts splices one of transport-broker.js / transport-embedded.js here.
|
|
445
330
|
|
|
446
331
|
// `nStr` is the decimal byte count as a string (same string-param pattern as
|
|
447
332
|
// __sbEnv). Returns a bytestring of that many CSPRNG bytes, or '' on failure.
|
|
@@ -469,8 +354,14 @@ function __sbRandomBytes(nStr) {
|
|
|
469
354
|
return out;
|
|
470
355
|
}
|
|
471
356
|
|
|
357
|
+
// #63 — every request carries the protocol version it was built against and an
|
|
358
|
+
// id unique to this process. The id is what makes a resend safe: the transport
|
|
359
|
+
// retries the *same bytes*, so a broker that already applied the request can
|
|
360
|
+
// recognise it and replay its answer instead of applying it twice.
|
|
361
|
+
var __sbReqId = 0;
|
|
362
|
+
|
|
472
363
|
function __sbRpc(op, extra) {
|
|
473
|
-
const req = { op };
|
|
364
|
+
const req = { v: 1, id: ++__sbReqId, op };
|
|
474
365
|
if (extra) for (const k in extra) req[k] = extra[k];
|
|
475
366
|
const reply = JSON.parse(__sbCall(JSON.stringify(req)));
|
|
476
367
|
if (reply && reply.ok === false) throw new Error(`sproutboat ${op}: ${reply.error || "failed"}`);
|
|
@@ -609,16 +500,19 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
609
500
|
target[name] = {
|
|
610
501
|
put(key, value, options) {
|
|
611
502
|
const o = options || {};
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
503
|
+
// #56 — the body goes out of band where the transport allows it.
|
|
504
|
+
return __sbR2Put(
|
|
505
|
+
name,
|
|
506
|
+
String(key),
|
|
507
|
+
value == null ? "" : String(value),
|
|
508
|
+
o.httpMetadata || {},
|
|
509
|
+
o.customMetadata || {},
|
|
510
|
+
).object;
|
|
619
511
|
},
|
|
620
512
|
get(key) {
|
|
621
|
-
|
|
513
|
+
// #56 — bytes come back out of band on a transport that supports it, so
|
|
514
|
+
// an object body is never JSON-escaped into a frame.
|
|
515
|
+
const r = __sbR2Get(name, String(key));
|
|
622
516
|
return r.found ? __sbR2Object(r.object, r.body == null ? "" : r.body) : null;
|
|
623
517
|
},
|
|
624
518
|
head(key) {
|
|
@@ -713,6 +607,34 @@ globalThis.__sbInstallBindings = function (target, bindings) {
|
|
|
713
607
|
};
|
|
714
608
|
}
|
|
715
609
|
|
|
610
|
+
// #48 — worker-to-worker. Same wire shape as outbound fetch, but the broker
|
|
611
|
+
// resolves the target itself and forwards it internally, so this is not
|
|
612
|
+
// egress and is not subject to the outbound allowlist.
|
|
613
|
+
for (let i = 0; i < (bindings.services || []).length; i++) {
|
|
614
|
+
const binding = bindings.services[i].binding;
|
|
615
|
+
target[binding] = {
|
|
616
|
+
fetch(input, init) {
|
|
617
|
+
const url = __sbIsStr(input) ? input : String((input && input.url) || "https://service/");
|
|
618
|
+
const opts = init || (!__sbIsStr(input) && input) || {};
|
|
619
|
+
const headers = [];
|
|
620
|
+
if (opts.headers) {
|
|
621
|
+
if (__sbIsFn(opts.headers.forEach)) opts.headers.forEach((v, k) => headers.push([k, v]));
|
|
622
|
+
else for (const k in opts.headers) headers.push([k, opts.headers[k]]);
|
|
623
|
+
}
|
|
624
|
+
const r = __sbRpc("service.fetch", {
|
|
625
|
+
binding,
|
|
626
|
+
url,
|
|
627
|
+
method: opts.method || "GET",
|
|
628
|
+
headers,
|
|
629
|
+
body: opts.body == null ? null : String(opts.body),
|
|
630
|
+
});
|
|
631
|
+
const respHeaders = new Headers();
|
|
632
|
+
for (let j = 0; j < (r.headers || []).length; j++) respHeaders.set(r.headers[j][0], r.headers[j][1]);
|
|
633
|
+
return new Response(r.body == null ? "" : r.body, { status: r.status || 502, headers: respHeaders });
|
|
634
|
+
},
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
|
|
716
638
|
if ((bindings.outbound || []).length > 0) {
|
|
717
639
|
globalThis.fetch = function (input, init) {
|
|
718
640
|
const url = __sbIsStr(input) ? input : String(input.url);
|
|
@@ -858,6 +780,19 @@ function __sbDOStorage(cls, id) {
|
|
|
858
780
|
deleteAll() {
|
|
859
781
|
__sbRpc("do.storage.delete_all", { cls, id });
|
|
860
782
|
},
|
|
783
|
+
// #125 — alarms. Cloudflare takes a Date or epoch ms; at most one is
|
|
784
|
+
// pending per object, so setting one replaces any earlier alarm.
|
|
785
|
+
setAlarm(when) {
|
|
786
|
+
const at = when instanceof Date ? when.getTime() : Number(when);
|
|
787
|
+
__sbRpc("do.alarm.set", { cls, id, at: at });
|
|
788
|
+
},
|
|
789
|
+
getAlarm() {
|
|
790
|
+
const r = __sbRpc("do.alarm.get", { cls, id });
|
|
791
|
+
return r.at == null ? null : r.at;
|
|
792
|
+
},
|
|
793
|
+
deleteAlarm() {
|
|
794
|
+
__sbRpc("do.alarm.delete", { cls, id });
|
|
795
|
+
},
|
|
861
796
|
list(options) {
|
|
862
797
|
const o = options || {};
|
|
863
798
|
const r = __sbRpc("do.storage.list", {
|
|
@@ -898,7 +833,14 @@ function __sbEnv(name) {
|
|
|
898
833
|
|
|
899
834
|
function __sbTriggerAuthed(request) {
|
|
900
835
|
const want = __sbEnv("SB_BROKER_TOKEN");
|
|
901
|
-
|
|
836
|
+
// No token configured means no caller can be trusted to send one, so refuse
|
|
837
|
+
// rather than wave the request through. Every path that legitimately delivers
|
|
838
|
+
// a trigger over HTTP sets SB_BROKER_TOKEN — the supervisor per deployment,
|
|
839
|
+
// `sproutboat dev`, the standalone launcher. The one build that has no token
|
|
840
|
+
// is the embedded binary (#15), which fires its own triggers in-process and
|
|
841
|
+
// is also the one most likely to be listening on a public interface: exactly
|
|
842
|
+
// where "anyone may invoke scheduled()" would be a hole.
|
|
843
|
+
if (!want) return false;
|
|
902
844
|
return request.headers.get("x-sb-token") === want;
|
|
903
845
|
}
|
|
904
846
|
|
|
@@ -931,50 +873,67 @@ globalThis.__sbEntry = function (handlers, request) {
|
|
|
931
873
|
|
|
932
874
|
if (trigger === "queue") {
|
|
933
875
|
if (!__sbIsFn(handlers.queue)) return new Response("no queue handler", { status: 404 });
|
|
876
|
+
const result = __sbRunQueueBatch(handlers, __sbReadJson(request));
|
|
877
|
+
return new Response(JSON.stringify(result), { headers: { "content-type": "application/json" } });
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
if (trigger === "alarm") {
|
|
934
881
|
const body = __sbReadJson(request);
|
|
935
|
-
const
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
for (let i = 0; i < raw.length; i++) {
|
|
940
|
-
const m = raw[i];
|
|
941
|
-
const msg = {
|
|
942
|
-
id: m.id,
|
|
943
|
-
timestamp: m.timestamp,
|
|
944
|
-
attempts: m.attempts || 1,
|
|
945
|
-
body: __sbTryParse(m.body),
|
|
946
|
-
ack() {
|
|
947
|
-
if (acked.indexOf(m.id) === -1) acked.push(m.id);
|
|
948
|
-
},
|
|
949
|
-
retry() {
|
|
950
|
-
if (retried.indexOf(m.id) === -1) retried.push(m.id);
|
|
951
|
-
},
|
|
952
|
-
};
|
|
953
|
-
messages.push(msg);
|
|
954
|
-
}
|
|
955
|
-
const batch = {
|
|
956
|
-
queue: body.queue || "",
|
|
957
|
-
messages,
|
|
958
|
-
ackAll() {
|
|
959
|
-
for (let i = 0; i < messages.length; i++) messages[i].ack();
|
|
960
|
-
},
|
|
961
|
-
retryAll() {
|
|
962
|
-
for (let i = 0; i < messages.length; i++) messages[i].retry();
|
|
963
|
-
},
|
|
964
|
-
};
|
|
965
|
-
handlers.queue(batch);
|
|
966
|
-
// default: any message neither acked nor retried is treated as acked
|
|
967
|
-
for (let i = 0; i < messages.length; i++) {
|
|
968
|
-
if (acked.indexOf(messages[i].id) === -1 && retried.indexOf(messages[i].id) === -1) acked.push(messages[i].id);
|
|
969
|
-
}
|
|
970
|
-
return new Response(JSON.stringify({ ack: acked, retry: retried }), {
|
|
971
|
-
headers: { "content-type": "application/json" },
|
|
972
|
-
});
|
|
882
|
+
const inst = __sbGetDOInstance(String(body.cls || ""), String(body.id || ""));
|
|
883
|
+
if (!__sbIsFn(inst.alarm)) return new Response("no alarm handler", { status: 404 });
|
|
884
|
+
inst.alarm();
|
|
885
|
+
return new Response("", { status: 204 });
|
|
973
886
|
}
|
|
974
887
|
|
|
975
888
|
return new Response("unknown trigger", { status: 400 });
|
|
976
889
|
};
|
|
977
890
|
|
|
891
|
+
/**
|
|
892
|
+
* Run one queue batch through the handler and report what it acked.
|
|
893
|
+
*
|
|
894
|
+
* Shared so the two ways a batch can arrive agree: over HTTP from the broker
|
|
895
|
+
* (deployed, and the phase-0 standalone launcher), or straight from the local
|
|
896
|
+
* timer in an embedded binary that has no broker to be delivered from.
|
|
897
|
+
*/
|
|
898
|
+
function __sbRunQueueBatch(handlers, body) {
|
|
899
|
+
const acked = [];
|
|
900
|
+
const retried = [];
|
|
901
|
+
const raw = body.messages || [];
|
|
902
|
+
const messages = [];
|
|
903
|
+
for (let i = 0; i < raw.length; i++) {
|
|
904
|
+
const m = raw[i];
|
|
905
|
+
const msg = {
|
|
906
|
+
id: m.id,
|
|
907
|
+
timestamp: m.timestamp,
|
|
908
|
+
attempts: m.attempts || 1,
|
|
909
|
+
body: __sbTryParse(m.body),
|
|
910
|
+
ack() {
|
|
911
|
+
if (acked.indexOf(m.id) === -1) acked.push(m.id);
|
|
912
|
+
},
|
|
913
|
+
retry() {
|
|
914
|
+
if (retried.indexOf(m.id) === -1) retried.push(m.id);
|
|
915
|
+
},
|
|
916
|
+
};
|
|
917
|
+
messages.push(msg);
|
|
918
|
+
}
|
|
919
|
+
const batch = {
|
|
920
|
+
queue: body.queue || "",
|
|
921
|
+
messages,
|
|
922
|
+
ackAll() {
|
|
923
|
+
for (let i = 0; i < messages.length; i++) messages[i].ack();
|
|
924
|
+
},
|
|
925
|
+
retryAll() {
|
|
926
|
+
for (let i = 0; i < messages.length; i++) messages[i].retry();
|
|
927
|
+
},
|
|
928
|
+
};
|
|
929
|
+
handlers.queue(batch);
|
|
930
|
+
// default: any message neither acked nor retried is treated as acked
|
|
931
|
+
for (let i = 0; i < messages.length; i++) {
|
|
932
|
+
if (acked.indexOf(messages[i].id) === -1 && retried.indexOf(messages[i].id) === -1) acked.push(messages[i].id);
|
|
933
|
+
}
|
|
934
|
+
return { ack: acked, retry: retried };
|
|
935
|
+
}
|
|
936
|
+
|
|
978
937
|
function __sbReadJson(request) {
|
|
979
938
|
try {
|
|
980
939
|
return JSON.parse(request.body == null ? "{}" : String(request.body));
|