sproutboat 0.6.1 → 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/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: {},
@@ -152,6 +206,9 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
152
206
  return { type: entry.type, hash: entry.hash, body: readFileSync(abs, "utf8") };
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,6 +692,25 @@ 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) || "/";
@@ -564,6 +735,119 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
564
735
  }
565
736
  }
566
737
 
738
+ /**
739
+ * #63 — the ops that carry bytes rather than escaping them into the frame.
740
+ * Everything else goes through `dispatch` untouched.
741
+ */
742
+ async function dispatchBinary(msg: Frame, binary: Uint8Array): Promise<{ reply: Frame; bytes?: Uint8Array }> {
743
+ if (msg.op === "r2.put") {
744
+ // The body arrived as bytes; store it as a blob rather than a string, so
745
+ // a put costs one copy instead of an escape, a parse and a re-encode.
746
+ const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
747
+ const etag = createHash("sha256").update(binary).digest("hex");
748
+ const uploaded = new Date().toISOString();
749
+ store
750
+ .query(
751
+ "INSERT INTO r2 (bucket, key, body, size, etag, uploaded, http_json, custom_json) VALUES (?1,?2,?3,?4,?5,?6,?7,?8) " +
752
+ "ON CONFLICT (bucket, key) DO UPDATE SET body=?3, size=?4, etag=?5, uploaded=?6, http_json=?7, custom_json=?8",
753
+ )
754
+ .run(
755
+ bucket,
756
+ str(msg.key),
757
+ binary,
758
+ binary.byteLength,
759
+ etag,
760
+ uploaded,
761
+ JSON.stringify(msg.httpMetadata ?? {}),
762
+ JSON.stringify(msg.customMetadata ?? {}),
763
+ );
764
+ return {
765
+ reply: { ok: true, object: { key: str(msg.key), size: binary.byteLength, etag, uploaded } },
766
+ };
767
+ }
768
+
769
+ if (msg.op === "r2.get") {
770
+ const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
771
+ const row = store
772
+ .query<R2Row, [string, string]>("SELECT * FROM r2 WHERE bucket = ? AND key = ?")
773
+ .get(bucket, str(msg.key));
774
+ if (!row) return { reply: { ok: true, found: false } };
775
+ return { reply: { ok: true, found: true, object: r2Row(row) }, bytes: r2Bytes(row.body) };
776
+ }
777
+
778
+ return { reply: await dispatchOnce(msg) };
779
+ }
780
+
781
+ /** One wire frame in, one out. See `handleFrame` on the Broker type. */
782
+ async function handleFrame(payload: Buffer): Promise<Buffer> {
783
+ if (payload.length === 0 || payload[0] !== FRAME_V1) {
784
+ return encodeFrame(await handlePayload(payload.toString("utf8")));
785
+ }
786
+ try {
787
+ const jsonLen = payload.readUInt32LE(1);
788
+ const msg = jsonObject(parseJsonValue(payload.subarray(5, 5 + jsonLen).toString("utf8")));
789
+ if (!msg) throw new Error("request frame was not a JSON object");
790
+ if (token && str(msg.token) !== token) return frameOf(encodeV1({ ok: false, error: "unauthorized" }));
791
+ const binary = new Uint8Array(payload.subarray(5 + jsonLen));
792
+ const { reply, bytes } = await dispatchBinary(msg, binary);
793
+ return frameOf(encodeV1(reply, bytes));
794
+ } catch (e) {
795
+ return frameOf(encodeV1({ ok: false, error: e instanceof Error ? e.message : String(e) }));
796
+ }
797
+ }
798
+
799
+ /**
800
+ * #63 §3 — ops that must not be applied twice.
801
+ *
802
+ * The transport retries the same bytes when a connection dies, and a broker
803
+ * that already applied a request but never got its reply out would otherwise
804
+ * apply it again: a second queue message, a second INSERT. Reads are absent
805
+ * deliberately — replaying a `d1.query` costs nothing and caching its reply
806
+ * could hold megabytes.
807
+ */
808
+ const REPLAYABLE = new Set([
809
+ "kv.put",
810
+ "kv.delete",
811
+ "queue.send",
812
+ "queue.send_batch",
813
+ "d1.query",
814
+ "d1.exec",
815
+ "d1.batch",
816
+ "r2.put",
817
+ "r2.delete",
818
+ "do.storage.put",
819
+ "do.storage.delete",
820
+ "do.storage.delete_all",
821
+ "do.alarm.set",
822
+ "do.alarm.delete",
823
+ "ae.write",
824
+ ]);
825
+
826
+ /** How many recent replies to keep. A resend follows its original within
827
+ * milliseconds, so this only has to outlive a reconnect, not a session. */
828
+ const REPLAY_WINDOW = 256;
829
+
830
+ /**
831
+ * Run `msg`, or replay what it answered last time. Keyed by the id the sprout
832
+ * put on the request; a request without one is always run.
833
+ */
834
+ async function dispatchOnce(msg: Frame): Promise<Frame> {
835
+ const id = isSafeInteger(msg.id) ? msg.id : null;
836
+ const op = str(msg.op);
837
+ if (id === null || !REPLAYABLE.has(op)) return dispatch(msg);
838
+ const key = `${op}:${id}`;
839
+ const seen = replayed.get(key);
840
+ if (seen) return seen;
841
+ const reply = await dispatch(msg);
842
+ replayed.set(key, reply);
843
+ // Insertion-ordered, so the oldest key is the first one out.
844
+ if (replayed.size > REPLAY_WINDOW) {
845
+ const oldest = replayed.keys().next().value;
846
+ if (oldest !== undefined) replayed.delete(oldest);
847
+ }
848
+ return reply;
849
+ }
850
+
567
851
  async function handlePayload(payload: string): Promise<Frame> {
568
852
  const nl = payload.indexOf("\n");
569
853
  const gotToken = nl === -1 ? "" : payload.slice(0, nl);
@@ -572,7 +856,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
572
856
  try {
573
857
  const msg = jsonObject(parseJsonValue(json));
574
858
  if (!msg) throw new Error("request frame was not a JSON object");
575
- return await dispatch(msg);
859
+ return await dispatchOnce(msg);
576
860
  } catch (e) {
577
861
  return { ok: false, error: e instanceof Error ? e.message : String(e) };
578
862
  }
@@ -583,7 +867,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
583
867
  const QUEUE_BATCH = 10;
584
868
  const QUEUE_MAX_ATTEMPTS = 5;
585
869
 
586
- async function deliverTrigger(kind: "scheduled" | "queue", body: JsonObject): Promise<Response | null> {
870
+ async function deliverTrigger(kind: "scheduled" | "queue" | "alarm", body: JsonObject): Promise<Response | null> {
587
871
  if (!opts.sproutUrl) return null;
588
872
  try {
589
873
  return await doFetch(opts.sproutUrl, {
@@ -643,8 +927,48 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
643
927
  }
644
928
  }
645
929
 
930
+ const ALARM_MAX_ATTEMPTS = 5;
931
+
932
+ /**
933
+ * #125 — fire alarms that are due.
934
+ *
935
+ * Claim-then-deliver, not deliver-then-delete: `alarm()` is allowed to call
936
+ * `setAlarm()` for its next run, and that write lands *during* delivery. If
937
+ * we deleted afterwards we would erase the alarm the handler just scheduled,
938
+ * which is how a self-rescheduling object silently stops. Deleting first
939
+ * means the handler's row is a fresh insert nothing else touches.
940
+ *
941
+ * A failed delivery re-arms with the queue consumer's policy — 5s apart, five
942
+ * attempts — and `DO NOTHING` so a re-arm never overwrites an alarm the
943
+ * handler set itself.
944
+ */
945
+ function fireAlarmsOnce(): void {
946
+ if (!opts.sproutUrl || bindings.do.length === 0) return;
947
+ const now = Date.now();
948
+ const due = db
949
+ .query<{ cls: string; id: string; at: number; attempts: number }, [number]>(
950
+ "SELECT cls, id, at, attempts FROM do_alarm WHERE at <= ? ORDER BY at LIMIT 10",
951
+ )
952
+ .all(now);
953
+ if (due.length === 0) return;
954
+ const claim = db.query("DELETE FROM do_alarm WHERE cls = ? AND id = ?");
955
+ const rearm = db.query(
956
+ "INSERT INTO do_alarm (cls, id, at, attempts) VALUES (?1,?2,?3,?4) ON CONFLICT (cls, id) DO NOTHING",
957
+ );
958
+ for (const row of due) {
959
+ claim.run(row.cls, row.id);
960
+ void deliverTrigger("alarm", { cls: row.cls, id: row.id, scheduledTime: row.at }).then((res) => {
961
+ if (res && res.ok) return;
962
+ const attempts = row.attempts + 1;
963
+ if (attempts >= ALARM_MAX_ATTEMPTS) return; // give up rather than spin forever
964
+ rearm.run(row.cls, row.id, Date.now() + 5_000, attempts);
965
+ });
966
+ }
967
+ }
968
+
646
969
  if (opts.sproutUrl) {
647
970
  if (bindings.queues.length > 0) timers.push(setInterval(drainQueuesOnce, 500));
971
+ if (bindings.do.length > 0) timers.push(setInterval(fireAlarmsOnce, 500));
648
972
  if (bindings.crons.length > 0) {
649
973
  let lastTick = "";
650
974
  timers.push(
@@ -664,6 +988,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
664
988
  return {
665
989
  dispatch,
666
990
  handlePayload,
991
+ handleFrame,
667
992
  close: () => {
668
993
  for (const t of timers) clearInterval(t);
669
994
  for (const conn of d1Conns.values()) conn.close();
@@ -712,28 +1037,41 @@ export type BrokerServer = { port: number; stop(): void };
712
1037
 
713
1038
  /** Start the TCP listener. Returns the bound port. */
714
1039
  export function listen(broker: Broker, hostname: string, port: number): BrokerServer {
715
- const server = Bun.listen<{ buf: Buffer }>({
1040
+ // Chunks are held in a list and joined once, when a whole frame has arrived.
1041
+ // Concatenating on every chunk copies the accumulated buffer each time, which
1042
+ // is quadratic in the body size: an 8 MB object arriving in 64 KB pieces
1043
+ // copied hundreds of megabytes before anything was parsed.
1044
+ const server = Bun.listen<{ chunks: Buffer[]; size: number }>({
716
1045
  hostname,
717
1046
  port,
718
1047
  socket: {
719
1048
  open(socket) {
720
- socket.data = { buf: Buffer.alloc(0) };
1049
+ socket.data = { chunks: [], size: 0 };
721
1050
  },
722
1051
  async data(socket, chunk) {
723
1052
  const state = socket.data;
724
- state.buf = state.buf.length ? Buffer.concat([state.buf, chunk]) : chunk;
1053
+ state.chunks.push(chunk);
1054
+ state.size += chunk.length;
725
1055
  for (;;) {
726
- if (state.buf.length < 4) return;
727
- const len = state.buf.readUInt32LE(0);
1056
+ if (state.size < 4) return;
1057
+ if (state.chunks.length > 1) {
1058
+ state.chunks = [Buffer.concat(state.chunks, state.size)];
1059
+ }
1060
+ const buf = state.chunks[0];
1061
+ const len = buf.readUInt32LE(0);
728
1062
  if (len > MAX_FRAME) {
729
1063
  socket.write(encodeFrame({ ok: false, error: "frame too large" }));
730
1064
  socket.end();
731
1065
  return;
732
1066
  }
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)));
1067
+ if (state.size < 4 + len) return;
1068
+ // Kept as bytes: a v1 frame carries binary after its JSON, and
1069
+ // decoding the whole payload as utf8 would corrupt it.
1070
+ const payload = buf.subarray(4, 4 + len);
1071
+ const rest = buf.subarray(4 + len);
1072
+ state.chunks = rest.length ? [Buffer.from(rest)] : [];
1073
+ state.size = rest.length;
1074
+ socket.write(await broker.handleFrame(payload));
737
1075
  }
738
1076
  },
739
1077
  },
@@ -753,6 +1091,8 @@ if (import.meta.main) {
753
1091
  secrets: { type: "string" },
754
1092
  "sprout-url": { type: "string" },
755
1093
  "assets-dir": { type: "string" },
1094
+ "edge-url": { type: "string" },
1095
+ services: { type: "string" },
756
1096
  },
757
1097
  });
758
1098
  // SAFETY: --bindings and --secrets are the artifact's own bindings.json /
@@ -764,6 +1104,22 @@ if (import.meta.main) {
764
1104
  const secrets: Record<string, string> | undefined = values.secrets
765
1105
  ? (JSON.parse(readFileSync(values.secrets, "utf8")) as Record<string, string>)
766
1106
  : undefined;
1107
+ // #48 — binding -> hostname, resolved by the control plane at activation and
1108
+ // passed as JSON. Malformed input disables service calls rather than taking
1109
+ // the broker down: every other binding still works.
1110
+ let services: Record<string, string> | undefined;
1111
+ if (values.services) {
1112
+ try {
1113
+ const parsed = jsonObject(parseJsonValue(values.services));
1114
+ if (parsed) {
1115
+ services = Object.fromEntries(
1116
+ Object.entries(parsed).flatMap(([binding, host]) => (isString(host) ? [[binding, host] as const] : [])),
1117
+ );
1118
+ }
1119
+ } catch {
1120
+ console.error("sproutboat broker: --services is not valid JSON; service bindings disabled");
1121
+ }
1122
+ }
767
1123
  const broker = createBroker({
768
1124
  db: values.db,
769
1125
  dataDir: values["data-dir"],
@@ -773,6 +1129,8 @@ if (import.meta.main) {
773
1129
  secrets,
774
1130
  sproutUrl: values["sprout-url"] ?? process.env.SB_SPROUT_URL,
775
1131
  assetsDir: values["assets-dir"],
1132
+ edgeUrl: values["edge-url"],
1133
+ services,
776
1134
  });
777
1135
  const { port } = listen(broker, "127.0.0.1", Number(values.port ?? process.env.SB_BROKER_PORT ?? 0));
778
1136
  console.log(
package/src/build.ts CHANGED
@@ -3,7 +3,9 @@ import { cp, mkdir, readFile, stat, writeFile } from "node:fs/promises";
3
3
  import { resolve } from "node:path";
4
4
  import { walkAssets, type AssetManifest } from "./assets";
5
5
  import { resourceRefs, type SproutboatConfig } from "./config";
6
- import { compileSprout } from "./compile";
6
+ import { ensureSqliteObject } from "./sqlite";
7
+ import { ensureBearssl } from "./bearssl";
8
+ import { compileSprout, type Transport } from "./compile";
7
9
  import {
8
10
  ARTIFACT_SCHEMA_VERSION,
9
11
  CAPABILITY_PROFILE,
@@ -30,6 +32,9 @@ export type BuildInput = {
30
32
  * the real target, which is what stops the result being deployed.
31
33
  */
32
34
  target?: "linux-x86_64" | "host";
35
+ /** #15 — `embedded` compiles SQLite into the sprout instead of a broker
36
+ * transport. Defaults to the broker transport. */
37
+ transport?: Transport;
33
38
  };
34
39
 
35
40
  export type BuildOutput = {
@@ -37,6 +42,10 @@ export type BuildOutput = {
37
42
  manifest: ArtifactManifest;
38
43
  };
39
44
 
45
+ /** Baking megabytes of assets into the module makes the Porffor compile crawl;
46
+ * past this it is the wrong tool and the error says what to do instead. */
47
+ const MAX_BAKED_ASSET_BYTES = 8_000_000;
48
+
40
49
  function digest(value: Uint8Array | string): `sha256:${string}` {
41
50
  return `sha256:${createHash("sha256").update(value).digest("hex")}`;
42
51
  }
@@ -77,6 +86,7 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
77
86
  queues: refsByKind.queue.map((ref) => ref.binding),
78
87
  analytics: input.config.analytics_engine_datasets ?? [],
79
88
  do: Object.entries(input.config.durable_objects ?? {}).map(([binding, className]) => ({ binding, className })),
89
+ services: input.config.services ?? [],
80
90
  crons: input.config.triggers?.crons ?? [],
81
91
  assets: input.config.assets?.binding ?? "",
82
92
  // Baked plain values, read as env.NAME. The broker never serves these (they
@@ -91,6 +101,43 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
91
101
  // for it — that download is the slowest part of a first local build.
92
102
  const host = input.target === "host";
93
103
  const zigBin = host ? undefined : await ensureZig();
104
+ // #15 — an embedded sprout carries its own storage and TLS instead of talking
105
+ // to a broker: SQLite and BearSSL are compiled once per target and added to
106
+ // the link line, and BearSSL's header to the compile line.
107
+ const embedded = input.transport === "embedded";
108
+ const target = input.target ?? "linux-x86_64";
109
+ const sqliteObject = embedded ? await ensureSqliteObject({ target, zigBin }) : null;
110
+ const tls = embedded ? await ensureBearssl({ target, zigBin }) : null;
111
+ const extraLink = [...(sqliteObject ? [sqliteObject] : []), ...(tls ? tls.objects : [])];
112
+ const extraCflags = tls ? ["-I", tls.includeDir] : [];
113
+ // #15 — an embedded binary has no files beside it, so assets are baked into
114
+ // the module. Read them from the source directory: the artifact copy happens
115
+ // after the compile, and the compile is what needs them. Bytes travel as a
116
+ // latin1 string, one char per byte, which is what the asset shim hands back.
117
+ let bakedAssets: { manifest: AssetManifest; files: Record<string, string> } | undefined;
118
+ if (input.transport === "embedded" && input.config.assets) {
119
+ const dir = resolve(input.projectDir, input.config.assets.directory);
120
+ const manifest: AssetManifest = {
121
+ notFound: input.config.assets.not_found_handling ?? "none",
122
+ runSproutFirst: input.config.assets.run_sprout_first ?? false,
123
+ files: walkAssets(dir),
124
+ };
125
+ const files: Record<string, string> = {};
126
+ let total = 0;
127
+ for (const key of Object.keys(manifest.files)) {
128
+ const bytes = await readFile(resolve(dir, `.${key}`));
129
+ total += bytes.byteLength;
130
+ if (total > MAX_BAKED_ASSET_BYTES) {
131
+ throw new Error(
132
+ `assets are too large to compile into a standalone binary (over ${MAX_BAKED_ASSET_BYTES / 1_000_000} MB). ` +
133
+ "Serve them from R2, or drop the assets binding and put a web server in front.",
134
+ );
135
+ }
136
+ files[key] = bytes.toString("latin1");
137
+ }
138
+ bakedAssets = { manifest, files };
139
+ }
140
+
94
141
  await compileSprout({
95
142
  sourcePath: input.sourcePath,
96
143
  source: input.source,
@@ -99,6 +146,12 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
99
146
  bindings,
100
147
  zigBin,
101
148
  target: input.target,
149
+ compatibilityDate: input.config.compatibility_date,
150
+ transport: input.transport,
151
+ appName: input.config.name,
152
+ assets: bakedAssets,
153
+ extraLink,
154
+ extraCflags,
102
155
  });
103
156
 
104
157
  const sprout = await readFile(sproutPath);
@@ -111,6 +164,7 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
111
164
  porfforVersion: porfforVersion(),
112
165
  esbuildVersion: esbuildVersion(),
113
166
  buildImage: toolchainStamp(),
167
+ compatibilityDate: input.config.compatibility_date,
114
168
  sourceHash,
115
169
  binaryHash: digest(sprout),
116
170
  binarySize: (await stat(sproutPath)).size,