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/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
- body: string;
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: string } | null => {
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, "utf8") };
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.text() };
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) return { ok: true, found: true, status: 200, type: hit.type, hash: hit.hash, body: hit.body };
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 { ok: true, found: true, status: 200, type: shell.type, hash: shell.hash, body: shell.body };
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) return { ok: true, found: false, status: 404, type: page.type, body: page.body };
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 dispatch(msg);
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
- const server = Bun.listen<{ buf: Buffer }>({
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 = { buf: Buffer.alloc(0) };
1092
+ socket.data = { chunks: [], size: 0 };
721
1093
  },
722
1094
  async data(socket, chunk) {
723
1095
  const state = socket.data;
724
- state.buf = state.buf.length ? Buffer.concat([state.buf, chunk]) : chunk;
1096
+ state.chunks.push(chunk);
1097
+ state.size += chunk.length;
725
1098
  for (;;) {
726
- if (state.buf.length < 4) return;
727
- const len = state.buf.readUInt32LE(0);
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.buf.length < 4 + len) return;
734
- const payload = Buffer.from(state.buf.subarray(4, 4 + len)).toString("utf8");
735
- state.buf = Buffer.from(state.buf.subarray(4 + len));
736
- socket.write(encodeFrame(await broker.handlePayload(payload)));
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(