sproutboat 0.7.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 +15 -5
- package/package.json +12 -6
- package/src/bearssl.ts +180 -0
- package/src/broker.ts +420 -19
- package/src/build.ts +61 -1
- package/src/compile.ts +94 -14
- package/src/config.ts +25 -0
- package/src/dev.ts +2 -0
- package/src/main.ts +260 -6
- package/src/native-fetch-prelude.js +130 -171
- package/src/patch-porffor.ts +104 -6
- package/src/sqlite.ts +99 -0
- package/src/standalone-build.ts +64 -0
- package/src/surface.ts +54 -5
- package/src/toolchain.ts +71 -0
- package/src/transport-broker.js +299 -0
- package/src/transport-embedded.js +1571 -0
- package/src/types-gen.ts +90 -0
- package/src/wrap.ts +46 -2
- package/types/sproutboat.d.ts +217 -0
- 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/broker.ts
CHANGED
|
@@ -23,6 +23,7 @@ import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
|
|
23
23
|
import { dirname, join, normalize, resolve } from "node:path";
|
|
24
24
|
import { parseArgs } from "node:util";
|
|
25
25
|
import { resolveAssetKey, type AssetManifest } from "./assets";
|
|
26
|
+
import { isSafeInteger } from "./json";
|
|
26
27
|
import { isBoolean, isString, jsonObject, parseJsonValue, type JsonObject, type JsonValue } from "./json";
|
|
27
28
|
|
|
28
29
|
export type Bindings = {
|
|
@@ -34,6 +35,8 @@ export type Bindings = {
|
|
|
34
35
|
queues: string[];
|
|
35
36
|
analytics: string[];
|
|
36
37
|
do: Array<{ binding: string; className: string }>;
|
|
38
|
+
/** #48 — worker-to-worker: binding name -> the project it calls. */
|
|
39
|
+
services: Array<{ binding: string; service: string }>;
|
|
37
40
|
crons: string[];
|
|
38
41
|
/** Static-asset binding name, or `""` when assets are edge-only. */
|
|
39
42
|
assets: string;
|
|
@@ -56,6 +59,21 @@ export type BrokerOptions = {
|
|
|
56
59
|
db?: string;
|
|
57
60
|
/** Directory for per-D1-binding SQLite files. Defaults to `<dirname(db)>/d1`, or in-memory when `db` is `:memory:`. */
|
|
58
61
|
dataDir?: string;
|
|
62
|
+
/**
|
|
63
|
+
* #48 — where to send a service-binding call: the node's edge, on loopback.
|
|
64
|
+
* Forwarding through the edge rather than dialling the target sprout directly
|
|
65
|
+
* reuses everything the request path already does — routing, the process
|
|
66
|
+
* pool, cold start, metrics, logs — and means the broker needs no knowledge
|
|
67
|
+
* of where a deployment is running.
|
|
68
|
+
*/
|
|
69
|
+
edgeUrl?: string;
|
|
70
|
+
/**
|
|
71
|
+
* #48 — binding name -> the target's hostname, resolved by the control plane
|
|
72
|
+
* at activation. Not part of the artifact: the same binary deploys to boxes
|
|
73
|
+
* with different domains, and a target can be redeployed without rebuilding
|
|
74
|
+
* its callers.
|
|
75
|
+
*/
|
|
76
|
+
services?: Record<string, string>;
|
|
59
77
|
/**
|
|
60
78
|
* #74 — directory holding one `<resource-id>.sqlite` per account-level KV / R2 /
|
|
61
79
|
* queue / D1 resource. Defaults to `<dirname(db)>/resources`, in-memory when
|
|
@@ -86,7 +104,8 @@ type D1Result = {
|
|
|
86
104
|
};
|
|
87
105
|
type R2Row = {
|
|
88
106
|
key: string;
|
|
89
|
-
|
|
107
|
+
/** TEXT from a v0 write, BLOB from a v1 one; sqlite hands each back as-is. */
|
|
108
|
+
body: string | Uint8Array;
|
|
90
109
|
size: number;
|
|
91
110
|
etag: string;
|
|
92
111
|
uploaded: string;
|
|
@@ -110,9 +129,43 @@ export type Broker = {
|
|
|
110
129
|
dispatch(msg: Frame): Promise<Frame>;
|
|
111
130
|
/** Verify the token line and dispatch a raw "<token>\n<json>" payload. */
|
|
112
131
|
handlePayload(payload: string): Promise<Frame>;
|
|
132
|
+
/**
|
|
133
|
+
* #63 — one wire frame in, one out, binary included.
|
|
134
|
+
*
|
|
135
|
+
* v0 payloads are `"<token>\n<json>"` and stay exactly as they were: a
|
|
136
|
+
* deployment built before this existed keeps working against a newer broker.
|
|
137
|
+
* v1 starts with a 0x01 marker and carries `[u32 LE json length][json][bytes]`,
|
|
138
|
+
* so an object body never has to be escaped into the JSON. A token's first
|
|
139
|
+
* character is never 0x01, which is what makes the two tellable apart without
|
|
140
|
+
* negotiation.
|
|
141
|
+
*/
|
|
142
|
+
handleFrame(payload: Buffer): Promise<Buffer>;
|
|
113
143
|
close(): void;
|
|
114
144
|
};
|
|
115
145
|
|
|
146
|
+
/** First byte of a v1 payload. */
|
|
147
|
+
export const FRAME_V1 = 1;
|
|
148
|
+
|
|
149
|
+
/** Wrap a payload in the [u32 LE length] header every frame carries. */
|
|
150
|
+
export function frameOf(payload: Buffer): Buffer {
|
|
151
|
+
const out = Buffer.allocUnsafe(4 + payload.length);
|
|
152
|
+
out.writeUInt32LE(payload.length, 0);
|
|
153
|
+
payload.copy(out, 4);
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Build a v1 payload: marker, json length, json, then the bytes. */
|
|
158
|
+
export function encodeV1(json: Frame, binary?: Uint8Array): Buffer {
|
|
159
|
+
const body = Buffer.from(JSON.stringify(json), "utf8");
|
|
160
|
+
const bin = binary ?? new Uint8Array(0);
|
|
161
|
+
const out = Buffer.allocUnsafe(1 + 4 + body.length + bin.length);
|
|
162
|
+
out.writeUInt8(FRAME_V1, 0);
|
|
163
|
+
out.writeUInt32LE(body.length, 1);
|
|
164
|
+
body.copy(out, 5);
|
|
165
|
+
if (bin.length) Buffer.from(bin.buffer, bin.byteOffset, bin.length).copy(out, 5 + body.length);
|
|
166
|
+
return out;
|
|
167
|
+
}
|
|
168
|
+
|
|
116
169
|
export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
117
170
|
const bindings: Bindings = {
|
|
118
171
|
kv: [],
|
|
@@ -123,6 +176,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
123
176
|
queues: [],
|
|
124
177
|
analytics: [],
|
|
125
178
|
do: [],
|
|
179
|
+
services: [],
|
|
126
180
|
crons: [],
|
|
127
181
|
assets: "",
|
|
128
182
|
resources: {},
|
|
@@ -142,16 +196,19 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
142
196
|
assetManifest = JSON.parse(readFileSync(manifestPath, "utf8")) as AssetManifest;
|
|
143
197
|
}
|
|
144
198
|
}
|
|
145
|
-
const readAsset = (path: string): { type: string; hash: string; body:
|
|
199
|
+
const readAsset = (path: string): { type: string; hash: string; body: Uint8Array } | null => {
|
|
146
200
|
if (!assetsDir || !assetManifest) return null;
|
|
147
201
|
const entry = assetManifest.files[path];
|
|
148
202
|
if (!entry) return null;
|
|
149
203
|
// path keys are `/`-prefixed and `..`-free (walkAssets), but re-check.
|
|
150
204
|
const abs = normalize(join(assetsDir, path));
|
|
151
205
|
if (!abs.startsWith(assetsDir)) return null;
|
|
152
|
-
return { type: entry.type, hash: entry.hash, body: readFileSync(abs
|
|
206
|
+
return { type: entry.type, hash: entry.hash, body: readFileSync(abs) };
|
|
153
207
|
};
|
|
154
208
|
const token = opts.token ?? "";
|
|
209
|
+
/** #63 §3 — replies to recent non-idempotent requests, keyed by op and id. */
|
|
210
|
+
const replayed = new Map<string, Frame>();
|
|
211
|
+
const serviceHosts = opts.services ?? {};
|
|
155
212
|
const doFetch = opts.fetchImpl ?? fetch;
|
|
156
213
|
|
|
157
214
|
const dbPath = opts.db ?? ":memory:";
|
|
@@ -193,6 +250,12 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
193
250
|
"CREATE TABLE IF NOT EXISTS ae (dataset TEXT NOT NULL, ts INTEGER NOT NULL, indexes_json TEXT NOT NULL, " +
|
|
194
251
|
"blobs_json TEXT NOT NULL, doubles_json TEXT NOT NULL)",
|
|
195
252
|
);
|
|
253
|
+
// #125 — at most one pending alarm per object, which is the Workers rule: a
|
|
254
|
+
// later setAlarm replaces the earlier one rather than queueing beside it.
|
|
255
|
+
db.exec(
|
|
256
|
+
"CREATE TABLE IF NOT EXISTS do_alarm (cls TEXT NOT NULL, id TEXT NOT NULL, at INTEGER NOT NULL, " +
|
|
257
|
+
"attempts INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (cls, id))",
|
|
258
|
+
);
|
|
196
259
|
|
|
197
260
|
// #74 — one SQLite file per account-level resource id, opened on first use.
|
|
198
261
|
const resourceDbs = new Map<string, Database>();
|
|
@@ -304,6 +367,16 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
304
367
|
};
|
|
305
368
|
};
|
|
306
369
|
|
|
370
|
+
/** An object body as bytes, whichever storage class it came back in. */
|
|
371
|
+
const r2Bytes = (body: string | Uint8Array): Uint8Array =>
|
|
372
|
+
body instanceof Uint8Array ? body : new TextEncoder().encode(body);
|
|
373
|
+
|
|
374
|
+
/** An object body as text, for the v0 reply shape that carries it in JSON.
|
|
375
|
+
* A binary object stored by a v1 client cannot survive this, which is why
|
|
376
|
+
* v1 exists — but returning a decoded string beats returning `{"0":...}`. */
|
|
377
|
+
const r2Text = (body: string | Uint8Array): string =>
|
|
378
|
+
body instanceof Uint8Array ? new TextDecoder().decode(body) : body;
|
|
379
|
+
|
|
307
380
|
const r2Row = (r: R2Row) => ({
|
|
308
381
|
key: r.key,
|
|
309
382
|
size: r.size,
|
|
@@ -313,6 +386,36 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
313
386
|
customMetadata: parseJsonValue(r.custom_json),
|
|
314
387
|
});
|
|
315
388
|
|
|
389
|
+
/**
|
|
390
|
+
* An outbound response body, refused past a cap.
|
|
391
|
+
*
|
|
392
|
+
* The size is the remote host's choice, and the body is held whole: measured
|
|
393
|
+
* on a standalone binary, a 100 MB response took resident memory from 43 MB
|
|
394
|
+
* to 321 MB. One allowlisted upstream having a bad day should not be able to
|
|
395
|
+
* end the process. 32 MiB by default, and the same variable the embedded
|
|
396
|
+
* transport reads so both backends agree.
|
|
397
|
+
*/
|
|
398
|
+
async function readCapped(response: Response, host: string): Promise<string> {
|
|
399
|
+
const cap = Number(process.env.SB_FETCH_MAX_BYTES) || 32 * 1024 * 1024;
|
|
400
|
+
const declared = Number(response.headers.get("content-length") || 0);
|
|
401
|
+
if (declared > cap) throw new Error(`response exceeds SB_FETCH_MAX_BYTES from ${host}`);
|
|
402
|
+
const reader = response.body?.getReader();
|
|
403
|
+
if (!reader) return "";
|
|
404
|
+
const parts: Uint8Array[] = [];
|
|
405
|
+
let total = 0;
|
|
406
|
+
for (;;) {
|
|
407
|
+
const { done, value } = await reader.read();
|
|
408
|
+
if (done) break;
|
|
409
|
+
total += value.byteLength;
|
|
410
|
+
if (total > cap) {
|
|
411
|
+
await reader.cancel();
|
|
412
|
+
throw new Error(`response exceeds SB_FETCH_MAX_BYTES from ${host}`);
|
|
413
|
+
}
|
|
414
|
+
parts.push(value);
|
|
415
|
+
}
|
|
416
|
+
return new TextDecoder().decode(Buffer.concat(parts));
|
|
417
|
+
}
|
|
418
|
+
|
|
316
419
|
async function proxyFetch(msg: Frame): Promise<Frame> {
|
|
317
420
|
let url: URL;
|
|
318
421
|
try {
|
|
@@ -338,7 +441,53 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
338
441
|
});
|
|
339
442
|
const outHeaders: Array<[string, string]> = [];
|
|
340
443
|
res.headers.forEach((v, k) => outHeaders.push([k, v]));
|
|
341
|
-
return { ok: true, status: res.status, headers: outHeaders, body: await res.
|
|
444
|
+
return { ok: true, status: res.status, headers: outHeaders, body: await readCapped(res, url.host) };
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* #48 — forward a service-binding call to the target deployment via the edge.
|
|
449
|
+
*
|
|
450
|
+
* The Host header is the whole routing decision: the edge matches a route by
|
|
451
|
+
* hostname, so this is exactly the request the outside world would make,
|
|
452
|
+
* minus the trip through the network (and therefore minus TLS and the
|
|
453
|
+
* default-deny egress rules, which is why this is not `fetch`).
|
|
454
|
+
*/
|
|
455
|
+
async function serviceFetch(msg: Frame): Promise<Frame> {
|
|
456
|
+
const binding = str(msg.binding);
|
|
457
|
+
const declared = bindings.services.find((entry) => entry.binding === binding);
|
|
458
|
+
if (!declared) throw new Error(`service not bound: ${binding}`);
|
|
459
|
+
const host = serviceHosts[binding];
|
|
460
|
+
// Declared in the artifact but unresolved at activation: the target project
|
|
461
|
+
// does not exist, or is not deployed yet. Say which, rather than 502.
|
|
462
|
+
if (!host) throw new Error(`service ${binding} -> "${declared.service}" is not deployed on this control plane`);
|
|
463
|
+
if (!opts.edgeUrl) throw new Error(`service ${binding} cannot be called: this broker has no edge url`);
|
|
464
|
+
|
|
465
|
+
let path = "/";
|
|
466
|
+
try {
|
|
467
|
+
const parsed = new URL(str(msg.url));
|
|
468
|
+
path = `${parsed.pathname}${parsed.search}`;
|
|
469
|
+
} catch {
|
|
470
|
+
path = str(msg.url) || "/";
|
|
471
|
+
if (!path.startsWith("/")) path = `/${path}`;
|
|
472
|
+
}
|
|
473
|
+
const headers = new Headers();
|
|
474
|
+
if (Array.isArray(msg.headers)) {
|
|
475
|
+
for (const pair of msg.headers) {
|
|
476
|
+
if (Array.isArray(pair) && pair.length === 2) headers.set(str(pair[0]), str(pair[1]));
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
headers.set("host", host);
|
|
480
|
+
const method = str(msg.method || "GET").toUpperCase();
|
|
481
|
+
const target = new URL(path, opts.edgeUrl);
|
|
482
|
+
const res = await doFetch(target, {
|
|
483
|
+
method,
|
|
484
|
+
headers,
|
|
485
|
+
body: msg.body == null || method === "GET" || method === "HEAD" ? undefined : str(msg.body),
|
|
486
|
+
redirect: "manual",
|
|
487
|
+
});
|
|
488
|
+
const outHeaders: Array<[string, string]> = [];
|
|
489
|
+
res.headers.forEach((v, k) => outHeaders.push([k, v]));
|
|
490
|
+
return { ok: true, status: res.status, headers: outHeaders, body: await readCapped(res, host) };
|
|
342
491
|
}
|
|
343
492
|
|
|
344
493
|
async function dispatch(msg: Frame): Promise<Frame> {
|
|
@@ -378,6 +527,9 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
378
527
|
case "fetch":
|
|
379
528
|
return proxyFetch(msg);
|
|
380
529
|
|
|
530
|
+
case "service.fetch":
|
|
531
|
+
return serviceFetch(msg);
|
|
532
|
+
|
|
381
533
|
case "d1.query": {
|
|
382
534
|
const conn = d1(requireD1(msg.db));
|
|
383
535
|
return { ok: true, ...d1Run(conn, str(msg.sql), sqlParams(msg.params)) };
|
|
@@ -425,7 +577,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
425
577
|
.query<R2Row, [string, string]>("SELECT * FROM r2 WHERE bucket = ? AND key = ?")
|
|
426
578
|
.get(bucket, str(msg.key));
|
|
427
579
|
if (!row) return { ok: true, found: false };
|
|
428
|
-
return { ok: true, found: true, object: r2Row(row), body: msg.op === "r2.get" ? row.body : undefined };
|
|
580
|
+
return { ok: true, found: true, object: r2Row(row), body: msg.op === "r2.get" ? r2Text(row.body) : undefined };
|
|
429
581
|
}
|
|
430
582
|
case "r2.delete": {
|
|
431
583
|
const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
|
|
@@ -540,21 +692,62 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
540
692
|
return { ok: true, entries: rows.map((r) => [r.key, r.value]) };
|
|
541
693
|
}
|
|
542
694
|
|
|
695
|
+
// #125 — alarms. `at` is epoch ms; setting one replaces any pending alarm
|
|
696
|
+
// for that object, and attempts resets because this is a fresh schedule.
|
|
697
|
+
case "do.alarm.set":
|
|
698
|
+
db.query(
|
|
699
|
+
"INSERT INTO do_alarm (cls, id, at, attempts) VALUES (?1,?2,?3,0) " +
|
|
700
|
+
"ON CONFLICT (cls, id) DO UPDATE SET at = ?3, attempts = 0",
|
|
701
|
+
).run(requireDoClass(msg.cls), str(msg.id), Math.trunc(Number(msg.at) || 0));
|
|
702
|
+
return { ok: true };
|
|
703
|
+
case "do.alarm.get": {
|
|
704
|
+
const row = db
|
|
705
|
+
.query<{ at: number }, [string, string]>("SELECT at FROM do_alarm WHERE cls = ? AND id = ?")
|
|
706
|
+
.get(requireDoClass(msg.cls), str(msg.id));
|
|
707
|
+
return { ok: true, at: row ? row.at : null };
|
|
708
|
+
}
|
|
709
|
+
case "do.alarm.delete": {
|
|
710
|
+
const r = db.query("DELETE FROM do_alarm WHERE cls = ? AND id = ?").run(requireDoClass(msg.cls), str(msg.id));
|
|
711
|
+
return { ok: true, deleted: r.changes > 0 };
|
|
712
|
+
}
|
|
713
|
+
|
|
543
714
|
case "assets.get": {
|
|
544
715
|
if (!bindings.assets) throw new Error("assets not bound");
|
|
545
716
|
const reqPath = str(msg.path) || "/";
|
|
546
717
|
const key = resolveAssetKey(reqPath, (k) => !!assetManifest?.files[k]);
|
|
547
718
|
const hit = key ? readAsset(key) : null;
|
|
548
|
-
if (hit)
|
|
719
|
+
if (hit)
|
|
720
|
+
return {
|
|
721
|
+
ok: true,
|
|
722
|
+
found: true,
|
|
723
|
+
status: 200,
|
|
724
|
+
type: hit.type,
|
|
725
|
+
hash: hit.hash,
|
|
726
|
+
body: new TextDecoder().decode(hit.body),
|
|
727
|
+
};
|
|
549
728
|
const nfh = assetManifest?.notFound ?? "none";
|
|
550
729
|
if (nfh === "single-page-application") {
|
|
551
730
|
const shell = readAsset("/index.html");
|
|
552
731
|
if (shell)
|
|
553
|
-
return {
|
|
732
|
+
return {
|
|
733
|
+
ok: true,
|
|
734
|
+
found: true,
|
|
735
|
+
status: 200,
|
|
736
|
+
type: shell.type,
|
|
737
|
+
hash: shell.hash,
|
|
738
|
+
body: new TextDecoder().decode(shell.body),
|
|
739
|
+
};
|
|
554
740
|
}
|
|
555
741
|
if (nfh === "404-page") {
|
|
556
742
|
const page = readAsset("/404.html");
|
|
557
|
-
if (page)
|
|
743
|
+
if (page)
|
|
744
|
+
return {
|
|
745
|
+
ok: true,
|
|
746
|
+
found: false,
|
|
747
|
+
status: 404,
|
|
748
|
+
type: page.type,
|
|
749
|
+
body: new TextDecoder().decode(page.body),
|
|
750
|
+
};
|
|
558
751
|
}
|
|
559
752
|
return { ok: true, found: false, status: 404, body: "Not Found" };
|
|
560
753
|
}
|
|
@@ -564,6 +757,140 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
564
757
|
}
|
|
565
758
|
}
|
|
566
759
|
|
|
760
|
+
/**
|
|
761
|
+
* #63 — the ops that carry bytes rather than escaping them into the frame.
|
|
762
|
+
* Everything else goes through `dispatch` untouched.
|
|
763
|
+
*/
|
|
764
|
+
async function dispatchBinary(msg: Frame, binary: Uint8Array): Promise<{ reply: Frame; bytes?: Uint8Array }> {
|
|
765
|
+
if (msg.op === "r2.put") {
|
|
766
|
+
// The body arrived as bytes; store it as a blob rather than a string, so
|
|
767
|
+
// a put costs one copy instead of an escape, a parse and a re-encode.
|
|
768
|
+
const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
|
|
769
|
+
const etag = createHash("sha256").update(binary).digest("hex");
|
|
770
|
+
const uploaded = new Date().toISOString();
|
|
771
|
+
store
|
|
772
|
+
.query(
|
|
773
|
+
"INSERT INTO r2 (bucket, key, body, size, etag, uploaded, http_json, custom_json) VALUES (?1,?2,?3,?4,?5,?6,?7,?8) " +
|
|
774
|
+
"ON CONFLICT (bucket, key) DO UPDATE SET body=?3, size=?4, etag=?5, uploaded=?6, http_json=?7, custom_json=?8",
|
|
775
|
+
)
|
|
776
|
+
.run(
|
|
777
|
+
bucket,
|
|
778
|
+
str(msg.key),
|
|
779
|
+
binary,
|
|
780
|
+
binary.byteLength,
|
|
781
|
+
etag,
|
|
782
|
+
uploaded,
|
|
783
|
+
JSON.stringify(msg.httpMetadata ?? {}),
|
|
784
|
+
JSON.stringify(msg.customMetadata ?? {}),
|
|
785
|
+
);
|
|
786
|
+
return {
|
|
787
|
+
reply: { ok: true, object: { key: str(msg.key), size: binary.byteLength, etag, uploaded } },
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
if (msg.op === "r2.get") {
|
|
792
|
+
const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
|
|
793
|
+
const row = store
|
|
794
|
+
.query<R2Row, [string, string]>("SELECT * FROM r2 WHERE bucket = ? AND key = ?")
|
|
795
|
+
.get(bucket, str(msg.key));
|
|
796
|
+
if (!row) return { reply: { ok: true, found: false } };
|
|
797
|
+
return { reply: { ok: true, found: true, object: r2Row(row) }, bytes: r2Bytes(row.body) };
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
if (msg.op === "assets.get") {
|
|
801
|
+
if (!bindings.assets) throw new Error("assets not bound");
|
|
802
|
+
const reqPath = str(msg.path) || "/";
|
|
803
|
+
const key = resolveAssetKey(reqPath, (candidate) => !!assetManifest?.files[candidate]);
|
|
804
|
+
let hit = key ? readAsset(key) : null;
|
|
805
|
+
let found = Boolean(hit);
|
|
806
|
+
let status = hit ? 200 : 404;
|
|
807
|
+
if (!hit && assetManifest?.notFound === "single-page-application") {
|
|
808
|
+
hit = readAsset("/index.html");
|
|
809
|
+
found = Boolean(hit);
|
|
810
|
+
status = hit ? 200 : 404;
|
|
811
|
+
} else if (!hit && assetManifest?.notFound === "404-page") {
|
|
812
|
+
hit = readAsset("/404.html");
|
|
813
|
+
}
|
|
814
|
+
if (!hit) return { reply: { ok: true, found: false, status: 404 }, bytes: new TextEncoder().encode("Not Found") };
|
|
815
|
+
return {
|
|
816
|
+
reply: { ok: true, found, status, type: hit.type, hash: hit.hash },
|
|
817
|
+
bytes: hit.body,
|
|
818
|
+
};
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
return { reply: await dispatchOnce(msg) };
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
/** One wire frame in, one out. See `handleFrame` on the Broker type. */
|
|
825
|
+
async function handleFrame(payload: Buffer): Promise<Buffer> {
|
|
826
|
+
if (payload.length === 0 || payload[0] !== FRAME_V1) {
|
|
827
|
+
return encodeFrame(await handlePayload(payload.toString("utf8")));
|
|
828
|
+
}
|
|
829
|
+
try {
|
|
830
|
+
const jsonLen = payload.readUInt32LE(1);
|
|
831
|
+
const msg = jsonObject(parseJsonValue(payload.subarray(5, 5 + jsonLen).toString("utf8")));
|
|
832
|
+
if (!msg) throw new Error("request frame was not a JSON object");
|
|
833
|
+
if (token && str(msg.token) !== token) return frameOf(encodeV1({ ok: false, error: "unauthorized" }));
|
|
834
|
+
const binary = new Uint8Array(payload.subarray(5 + jsonLen));
|
|
835
|
+
const { reply, bytes } = await dispatchBinary(msg, binary);
|
|
836
|
+
return frameOf(encodeV1(reply, bytes));
|
|
837
|
+
} catch (e) {
|
|
838
|
+
return frameOf(encodeV1({ ok: false, error: e instanceof Error ? e.message : String(e) }));
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
/**
|
|
843
|
+
* #63 §3 — ops that must not be applied twice.
|
|
844
|
+
*
|
|
845
|
+
* The transport retries the same bytes when a connection dies, and a broker
|
|
846
|
+
* that already applied a request but never got its reply out would otherwise
|
|
847
|
+
* apply it again: a second queue message, a second INSERT. Reads are absent
|
|
848
|
+
* deliberately — replaying a `d1.query` costs nothing and caching its reply
|
|
849
|
+
* could hold megabytes.
|
|
850
|
+
*/
|
|
851
|
+
const REPLAYABLE = new Set([
|
|
852
|
+
"kv.put",
|
|
853
|
+
"kv.delete",
|
|
854
|
+
"queue.send",
|
|
855
|
+
"queue.send_batch",
|
|
856
|
+
"d1.query",
|
|
857
|
+
"d1.exec",
|
|
858
|
+
"d1.batch",
|
|
859
|
+
"r2.put",
|
|
860
|
+
"r2.delete",
|
|
861
|
+
"do.storage.put",
|
|
862
|
+
"do.storage.delete",
|
|
863
|
+
"do.storage.delete_all",
|
|
864
|
+
"do.alarm.set",
|
|
865
|
+
"do.alarm.delete",
|
|
866
|
+
"ae.write",
|
|
867
|
+
]);
|
|
868
|
+
|
|
869
|
+
/** How many recent replies to keep. A resend follows its original within
|
|
870
|
+
* milliseconds, so this only has to outlive a reconnect, not a session. */
|
|
871
|
+
const REPLAY_WINDOW = 256;
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Run `msg`, or replay what it answered last time. Keyed by the id the sprout
|
|
875
|
+
* put on the request; a request without one is always run.
|
|
876
|
+
*/
|
|
877
|
+
async function dispatchOnce(msg: Frame): Promise<Frame> {
|
|
878
|
+
const id = isSafeInteger(msg.id) ? msg.id : null;
|
|
879
|
+
const op = str(msg.op);
|
|
880
|
+
if (id === null || !REPLAYABLE.has(op)) return dispatch(msg);
|
|
881
|
+
const key = `${op}:${id}`;
|
|
882
|
+
const seen = replayed.get(key);
|
|
883
|
+
if (seen) return seen;
|
|
884
|
+
const reply = await dispatch(msg);
|
|
885
|
+
replayed.set(key, reply);
|
|
886
|
+
// Insertion-ordered, so the oldest key is the first one out.
|
|
887
|
+
if (replayed.size > REPLAY_WINDOW) {
|
|
888
|
+
const oldest = replayed.keys().next().value;
|
|
889
|
+
if (oldest !== undefined) replayed.delete(oldest);
|
|
890
|
+
}
|
|
891
|
+
return reply;
|
|
892
|
+
}
|
|
893
|
+
|
|
567
894
|
async function handlePayload(payload: string): Promise<Frame> {
|
|
568
895
|
const nl = payload.indexOf("\n");
|
|
569
896
|
const gotToken = nl === -1 ? "" : payload.slice(0, nl);
|
|
@@ -572,7 +899,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
572
899
|
try {
|
|
573
900
|
const msg = jsonObject(parseJsonValue(json));
|
|
574
901
|
if (!msg) throw new Error("request frame was not a JSON object");
|
|
575
|
-
return await
|
|
902
|
+
return await dispatchOnce(msg);
|
|
576
903
|
} catch (e) {
|
|
577
904
|
return { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
578
905
|
}
|
|
@@ -583,7 +910,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
583
910
|
const QUEUE_BATCH = 10;
|
|
584
911
|
const QUEUE_MAX_ATTEMPTS = 5;
|
|
585
912
|
|
|
586
|
-
async function deliverTrigger(kind: "scheduled" | "queue", body: JsonObject): Promise<Response | null> {
|
|
913
|
+
async function deliverTrigger(kind: "scheduled" | "queue" | "alarm", body: JsonObject): Promise<Response | null> {
|
|
587
914
|
if (!opts.sproutUrl) return null;
|
|
588
915
|
try {
|
|
589
916
|
return await doFetch(opts.sproutUrl, {
|
|
@@ -643,8 +970,48 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
643
970
|
}
|
|
644
971
|
}
|
|
645
972
|
|
|
973
|
+
const ALARM_MAX_ATTEMPTS = 5;
|
|
974
|
+
|
|
975
|
+
/**
|
|
976
|
+
* #125 — fire alarms that are due.
|
|
977
|
+
*
|
|
978
|
+
* Claim-then-deliver, not deliver-then-delete: `alarm()` is allowed to call
|
|
979
|
+
* `setAlarm()` for its next run, and that write lands *during* delivery. If
|
|
980
|
+
* we deleted afterwards we would erase the alarm the handler just scheduled,
|
|
981
|
+
* which is how a self-rescheduling object silently stops. Deleting first
|
|
982
|
+
* means the handler's row is a fresh insert nothing else touches.
|
|
983
|
+
*
|
|
984
|
+
* A failed delivery re-arms with the queue consumer's policy — 5s apart, five
|
|
985
|
+
* attempts — and `DO NOTHING` so a re-arm never overwrites an alarm the
|
|
986
|
+
* handler set itself.
|
|
987
|
+
*/
|
|
988
|
+
function fireAlarmsOnce(): void {
|
|
989
|
+
if (!opts.sproutUrl || bindings.do.length === 0) return;
|
|
990
|
+
const now = Date.now();
|
|
991
|
+
const due = db
|
|
992
|
+
.query<{ cls: string; id: string; at: number; attempts: number }, [number]>(
|
|
993
|
+
"SELECT cls, id, at, attempts FROM do_alarm WHERE at <= ? ORDER BY at LIMIT 10",
|
|
994
|
+
)
|
|
995
|
+
.all(now);
|
|
996
|
+
if (due.length === 0) return;
|
|
997
|
+
const claim = db.query("DELETE FROM do_alarm WHERE cls = ? AND id = ?");
|
|
998
|
+
const rearm = db.query(
|
|
999
|
+
"INSERT INTO do_alarm (cls, id, at, attempts) VALUES (?1,?2,?3,?4) ON CONFLICT (cls, id) DO NOTHING",
|
|
1000
|
+
);
|
|
1001
|
+
for (const row of due) {
|
|
1002
|
+
claim.run(row.cls, row.id);
|
|
1003
|
+
void deliverTrigger("alarm", { cls: row.cls, id: row.id, scheduledTime: row.at }).then((res) => {
|
|
1004
|
+
if (res && res.ok) return;
|
|
1005
|
+
const attempts = row.attempts + 1;
|
|
1006
|
+
if (attempts >= ALARM_MAX_ATTEMPTS) return; // give up rather than spin forever
|
|
1007
|
+
rearm.run(row.cls, row.id, Date.now() + 5_000, attempts);
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
646
1012
|
if (opts.sproutUrl) {
|
|
647
1013
|
if (bindings.queues.length > 0) timers.push(setInterval(drainQueuesOnce, 500));
|
|
1014
|
+
if (bindings.do.length > 0) timers.push(setInterval(fireAlarmsOnce, 500));
|
|
648
1015
|
if (bindings.crons.length > 0) {
|
|
649
1016
|
let lastTick = "";
|
|
650
1017
|
timers.push(
|
|
@@ -664,6 +1031,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
664
1031
|
return {
|
|
665
1032
|
dispatch,
|
|
666
1033
|
handlePayload,
|
|
1034
|
+
handleFrame,
|
|
667
1035
|
close: () => {
|
|
668
1036
|
for (const t of timers) clearInterval(t);
|
|
669
1037
|
for (const conn of d1Conns.values()) conn.close();
|
|
@@ -712,28 +1080,41 @@ export type BrokerServer = { port: number; stop(): void };
|
|
|
712
1080
|
|
|
713
1081
|
/** Start the TCP listener. Returns the bound port. */
|
|
714
1082
|
export function listen(broker: Broker, hostname: string, port: number): BrokerServer {
|
|
715
|
-
|
|
1083
|
+
// Chunks are held in a list and joined once, when a whole frame has arrived.
|
|
1084
|
+
// Concatenating on every chunk copies the accumulated buffer each time, which
|
|
1085
|
+
// is quadratic in the body size: an 8 MB object arriving in 64 KB pieces
|
|
1086
|
+
// copied hundreds of megabytes before anything was parsed.
|
|
1087
|
+
const server = Bun.listen<{ chunks: Buffer[]; size: number }>({
|
|
716
1088
|
hostname,
|
|
717
1089
|
port,
|
|
718
1090
|
socket: {
|
|
719
1091
|
open(socket) {
|
|
720
|
-
socket.data = {
|
|
1092
|
+
socket.data = { chunks: [], size: 0 };
|
|
721
1093
|
},
|
|
722
1094
|
async data(socket, chunk) {
|
|
723
1095
|
const state = socket.data;
|
|
724
|
-
state.
|
|
1096
|
+
state.chunks.push(chunk);
|
|
1097
|
+
state.size += chunk.length;
|
|
725
1098
|
for (;;) {
|
|
726
|
-
if (state.
|
|
727
|
-
|
|
1099
|
+
if (state.size < 4) return;
|
|
1100
|
+
if (state.chunks.length > 1) {
|
|
1101
|
+
state.chunks = [Buffer.concat(state.chunks, state.size)];
|
|
1102
|
+
}
|
|
1103
|
+
const buf = state.chunks[0];
|
|
1104
|
+
const len = buf.readUInt32LE(0);
|
|
728
1105
|
if (len > MAX_FRAME) {
|
|
729
1106
|
socket.write(encodeFrame({ ok: false, error: "frame too large" }));
|
|
730
1107
|
socket.end();
|
|
731
1108
|
return;
|
|
732
1109
|
}
|
|
733
|
-
if (state.
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
1110
|
+
if (state.size < 4 + len) return;
|
|
1111
|
+
// Kept as bytes: a v1 frame carries binary after its JSON, and
|
|
1112
|
+
// decoding the whole payload as utf8 would corrupt it.
|
|
1113
|
+
const payload = buf.subarray(4, 4 + len);
|
|
1114
|
+
const rest = buf.subarray(4 + len);
|
|
1115
|
+
state.chunks = rest.length ? [Buffer.from(rest)] : [];
|
|
1116
|
+
state.size = rest.length;
|
|
1117
|
+
socket.write(await broker.handleFrame(payload));
|
|
737
1118
|
}
|
|
738
1119
|
},
|
|
739
1120
|
},
|
|
@@ -753,6 +1134,8 @@ if (import.meta.main) {
|
|
|
753
1134
|
secrets: { type: "string" },
|
|
754
1135
|
"sprout-url": { type: "string" },
|
|
755
1136
|
"assets-dir": { type: "string" },
|
|
1137
|
+
"edge-url": { type: "string" },
|
|
1138
|
+
services: { type: "string" },
|
|
756
1139
|
},
|
|
757
1140
|
});
|
|
758
1141
|
// SAFETY: --bindings and --secrets are the artifact's own bindings.json /
|
|
@@ -764,6 +1147,22 @@ if (import.meta.main) {
|
|
|
764
1147
|
const secrets: Record<string, string> | undefined = values.secrets
|
|
765
1148
|
? (JSON.parse(readFileSync(values.secrets, "utf8")) as Record<string, string>)
|
|
766
1149
|
: undefined;
|
|
1150
|
+
// #48 — binding -> hostname, resolved by the control plane at activation and
|
|
1151
|
+
// passed as JSON. Malformed input disables service calls rather than taking
|
|
1152
|
+
// the broker down: every other binding still works.
|
|
1153
|
+
let services: Record<string, string> | undefined;
|
|
1154
|
+
if (values.services) {
|
|
1155
|
+
try {
|
|
1156
|
+
const parsed = jsonObject(parseJsonValue(values.services));
|
|
1157
|
+
if (parsed) {
|
|
1158
|
+
services = Object.fromEntries(
|
|
1159
|
+
Object.entries(parsed).flatMap(([binding, host]) => (isString(host) ? [[binding, host] as const] : [])),
|
|
1160
|
+
);
|
|
1161
|
+
}
|
|
1162
|
+
} catch {
|
|
1163
|
+
console.error("sproutboat broker: --services is not valid JSON; service bindings disabled");
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
767
1166
|
const broker = createBroker({
|
|
768
1167
|
db: values.db,
|
|
769
1168
|
dataDir: values["data-dir"],
|
|
@@ -773,6 +1172,8 @@ if (import.meta.main) {
|
|
|
773
1172
|
secrets,
|
|
774
1173
|
sproutUrl: values["sprout-url"] ?? process.env.SB_SPROUT_URL,
|
|
775
1174
|
assetsDir: values["assets-dir"],
|
|
1175
|
+
edgeUrl: values["edge-url"],
|
|
1176
|
+
services,
|
|
776
1177
|
});
|
|
777
1178
|
const { port } = listen(broker, "127.0.0.1", Number(values.port ?? process.env.SB_BROKER_PORT ?? 0));
|
|
778
1179
|
console.log(
|