@sproutboat/wire 0.12.2 → 0.13.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
@@ -17,17 +17,19 @@
17
17
  * Encryption at rest and per-key TTLs are v2; redirects are never followed,
18
18
  * so there is nothing to re-validate.
19
19
  */
20
+ import { createD1Store } from "./d1";
21
+ import type { D1Pool } from "./d1-pool";
20
22
  import { Database, type Statement } from "bun:sqlite";
21
23
  import { createHash, randomBytes } from "node:crypto";
22
24
  import { appendFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statfsSync, statSync, writeFileSync } from "node:fs";
23
25
  import { open } from "node:fs/promises";
24
26
  import { dirname, join, normalize, resolve } from "node:path";
25
27
  import { parseArgs } from "node:util";
26
- import { resolveAssetKey, type AssetManifest } from "@sproutboat/assets";
28
+ import { matchRedirect, resolveAssetKey, type AssetManifest } from "@sproutboat/assets";
27
29
  import { egressAllowList } from "@sproutboat/runtime";
28
30
  import { EGRESS_ERROR_HEADER, EGRESS_TOKEN_HEADER, EGRESS_URL_HEADER, resolveAll, vettedFetch } from "./egress";
29
31
  import { isSafeInteger } from "./json";
30
- import { isBoolean, isString, jsonObject, parseJsonValue, type JsonObject, type JsonValue } from "./json";
32
+ import { isString, jsonObject, parseJsonValue, type JsonObject, type JsonValue } from "./json";
31
33
 
32
34
  export type Bindings = {
33
35
  kv: string[];
@@ -61,6 +63,8 @@ export type Frame = { [key: string]: JsonValue | undefined };
61
63
  export type FetchLike = (input: URL | string, init?: RequestInit) => Promise<Response>;
62
64
 
63
65
  export type BrokerOptions = {
66
+ /** Shared-mode SQL isolation, owned by the multi-session worker. */
67
+ d1Pool?: D1Pool;
64
68
  db?: string;
65
69
  /** Directory for per-D1-binding SQLite files. Defaults to `<dirname(db)>/d1`, or in-memory when `db` is `:memory:`. */
66
70
  dataDir?: string;
@@ -114,6 +118,9 @@ export type BrokerOptions = {
114
118
  /** Lets a supervisor prepare a replacement without two brokers claiming
115
119
  * queues, alarms, or cron ticks at once. Defaults to active. */
116
120
  dispatchEnabled?: () => boolean;
121
+ /** Bounded delivery timeout; leases outlive this deadline. */
122
+ deliveryTimeoutMs?: number;
123
+ deliveryLeaseMs?: number;
117
124
  /** Directory of published static assets (its sibling `assets.json` is the manifest). Backs `assets.get`. */
118
125
  assetsDir?: string;
119
126
  /** Injected in tests; defaults to the global `fetch`. */
@@ -129,12 +136,6 @@ export type BrokerOptions = {
129
136
  egressToken?: string;
130
137
  };
131
138
 
132
- type SqlParam = string | number | null;
133
- /** The `{ results, meta }` shape Cloudflare's D1 client expects back per statement. */
134
- type D1Result = {
135
- results: JsonValue[];
136
- meta: { duration: number; changes: number; last_row_id: number; rows_read: number };
137
- };
138
139
  type R2Row = {
139
140
  key: string;
140
141
  size: number;
@@ -165,11 +166,6 @@ type R2TransferRow = {
165
166
  };
166
167
  const MULTIPART_EXPIRY_MS = 7 * 24 * 60 * 60 * 1000;
167
168
  const isNumber = (v: JsonValue | undefined): v is number => Number.isFinite(v);
168
- const sqlParams = (v: JsonValue | undefined): SqlParam[] => {
169
- if (!Array.isArray(v)) return [];
170
- return v.map((p) => (p === null || isNumber(p) || isString(p) ? p : isBoolean(p) ? (p ? 1 : 0) : String(p)));
171
- };
172
-
173
169
  // One binding call = one frame, and an R2/KV value travels inside it as a JSON
174
170
  // string (escaping can inflate binary content several ×). 32 MiB keeps a ~25 MB
175
171
  // upload working; true large-object streaming is v2.
@@ -281,7 +277,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
281
277
  const openStore = (path: string): Database => {
282
278
  const conn = new Database(path, { create: true });
283
279
  conn.exec("PRAGMA journal_mode = WAL");
284
- conn.exec("PRAGMA synchronous = NORMAL");
280
+ conn.exec("PRAGMA synchronous = NORMAL; PRAGMA busy_timeout=5000");
285
281
  conn.exec(
286
282
  "CREATE TABLE IF NOT EXISTS kv (ns TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, " +
287
283
  "expires_at INTEGER, PRIMARY KEY (ns, key))",
@@ -327,6 +323,8 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
327
323
  // Every queue poll filters one partition's live rows and takes its earliest
328
324
  // visible batch. This additive index also upgrades existing resource files.
329
325
  conn.exec("CREATE INDEX IF NOT EXISTS mq_due ON mq (queue, dead, visible_at)");
326
+ if (!conn.query<{ name: string }, []>("PRAGMA table_info(mq)").all().some((c) => c.name === "claim_token"))
327
+ conn.exec("ALTER TABLE mq ADD COLUMN claim_token TEXT NOT NULL DEFAULT ''");
330
328
  return conn;
331
329
  };
332
330
 
@@ -354,6 +352,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
354
352
  );
355
353
  // Alarm polling reads the earliest due objects across classes.
356
354
  doDb.exec("CREATE INDEX IF NOT EXISTS do_alarm_due ON do_alarm (at)");
355
+ doDb.exec("CREATE TABLE IF NOT EXISTS do_alarm_delivery (cls TEXT NOT NULL, id TEXT NOT NULL, at INTEGER NOT NULL, attempts INTEGER NOT NULL, token TEXT NOT NULL, expires INTEGER NOT NULL, PRIMARY KEY(cls,id))");
357
356
  // #69 — fixed-window rate-limiter counters, per binding name + key.
358
357
  db.exec(
359
358
  "CREATE TABLE IF NOT EXISTS ratelimit (name TEXT NOT NULL, key TEXT NOT NULL, window_start INTEGER NOT NULL, " +
@@ -577,69 +576,17 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
577
576
  // binding wired to an account-level resource (#74) opens `<resource-id>.sqlite`
578
577
  // under `resourceDir` (survives redeploys); a bare-string binding keeps its
579
578
  // per-broker `<name>.sqlite` under `d1Dir`.
580
- const d1Conns = new Map<string, Database>();
581
- const d1 = (name: string): Database => {
579
+ const d1Store = createD1Store();
580
+ const d1Session = opts.d1Pool?.session();
581
+ const d1 = (name: string, msg: Frame): Frame | Promise<Frame> => {
582
582
  const resource = bindings.resources[name];
583
583
  const key = resource && resource.kind === "d1" ? resource.id : name;
584
- let conn = d1Conns.get(key);
585
- if (!conn) {
586
- const dir = resource && resource.kind === "d1" ? resourceDir : d1Dir;
587
- if (dir) mkdirSync(dir, { recursive: true });
588
- conn = new Database(dir ? join(dir, `${key}.sqlite`) : ":memory:", { create: true });
589
- conn.exec("PRAGMA journal_mode = WAL");
590
- conn.exec("PRAGMA synchronous = NORMAL");
591
- d1Conns.set(key, conn);
592
- }
593
- return conn;
594
- };
595
-
596
- // Run one statement, return CF-D1-shaped { results, meta }.
597
- const d1Run = (conn: Database, sql: string, params: SqlParam[]): D1Result => {
598
- const started = performance.now();
599
- // SQLite hands back column->(string|number|null|blob) rows, i.e. a JSON object.
600
- const results = conn.query<JsonObject, SqlParam[]>(sql).all(...params);
601
- const m = conn
602
- .query<{ changes: number; last_row_id: number }, []>(
603
- "SELECT changes() AS changes, last_insert_rowid() AS last_row_id",
604
- )
605
- .get();
606
- return {
607
- results,
608
- meta: {
609
- duration: performance.now() - started,
610
- changes: m?.changes ?? 0,
611
- last_row_id: m?.last_row_id ?? 0,
612
- rows_read: results.length,
613
- },
614
- };
615
- };
616
-
617
- // #164 — an online, integrity-checked snapshot of a D1 database via
618
- // VACUUM INTO. Standalone (transport-embedded) is the primary target; the
619
- // broker carries it too so one conformance suite covers both backends.
620
- const d1Backup = (conn: Database, dbName: string, name: string) => {
621
- const source = conn.filename;
622
- if (!source || source === ":memory:") throw new Error("d1 backup needs a file-backed database");
623
- // A caller-supplied name must be a plain basename; else use the default.
624
- const file = /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)
625
- ? name
626
- : `${dbName}-${new Date().toISOString().replace(/[:.]/g, "-")}.sqlite`;
627
- const dir = join(dirname(dirname(resolve(source))), "backups");
628
- mkdirSync(dir, { recursive: true });
629
- const dest = join(dir, file);
630
- if (existsSync(dest)) rmSync(dest); // VACUUM INTO refuses an existing target
631
- conn.run("VACUUM INTO ?", [dest]);
632
- const copy = new Database(dest, { readonly: true });
633
- try {
634
- const row = copy.query<{ integrity_check: string }, []>("PRAGMA integrity_check").get();
635
- if (row?.integrity_check !== "ok") {
636
- rmSync(dest, { force: true });
637
- throw new Error("integrity check failed on the backup");
638
- }
639
- } finally {
640
- copy.close();
641
- }
642
- return { path: dest, bytes: statSync(dest).size };
584
+ const dir = resource && resource.kind === "d1" ? resourceDir : d1Dir;
585
+ const path = dir ? resolve(dir, `${key}.sqlite`) : ":memory:";
586
+ // In-memory namespaces belong to this session; file resources share affinity.
587
+ const identity = path === ":memory:" ? `${token}:${dbPath}:${key}` : path;
588
+ const request = { key: identity, path, name, msg };
589
+ return d1Session ? d1Session.execute(request) : d1Store.execute({ ...request, owner: "dedicated" });
643
590
  };
644
591
 
645
592
  /**
@@ -948,7 +895,9 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
948
895
  const row = store.query<R2Row, [string, string]>("SELECT * FROM r2 WHERE bucket = ? AND key = ?").get(bucket, ticket.key);
949
896
  if (!row) return new Response("not found", { status: 404 });
950
897
  const etag = `"${row.etag}"`;
951
- if (request.headers.get("if-none-match") === etag) return new Response(null, { status: 304, headers: { etag } });
898
+ const noneMatch = request.headers.get("if-none-match");
899
+ if (noneMatch?.split(",").some((value) => value.trim() === "*" || value.trim().replace(/^W\//, "") === etag))
900
+ return new Response(null, { status: 304, headers: { etag } });
952
901
  const rangeHeader = request.headers.get("range");
953
902
  const range = parseRange(rangeHeader, row.size);
954
903
  if (!range) {
@@ -1365,25 +1314,11 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1365
1314
  case "service.fetch":
1366
1315
  return asTextFrame(await serviceFetch(msg));
1367
1316
 
1368
- case "d1.query": {
1369
- const conn = d1(requireD1(msg.db));
1370
- return { ok: true, ...d1Run(conn, str(msg.sql), sqlParams(msg.params)) };
1371
- }
1372
- case "d1.batch": {
1373
- const conn = d1(requireD1(msg.db));
1374
- const stmts = Array.isArray(msg.statements) ? msg.statements.map((s) => jsonObject(s) ?? {}) : [];
1375
- const runAll = conn.transaction(() => stmts.map((s) => d1Run(conn, str(s.sql), sqlParams(s.params))));
1376
- return { ok: true, results: runAll() };
1377
- }
1378
- case "d1.exec": {
1379
- const conn = d1(requireD1(msg.db));
1380
- conn.exec(str(msg.sql));
1381
- return { ok: true };
1382
- }
1383
- case "d1.backup": {
1384
- const name = requireD1(msg.db);
1385
- return { ok: true, ...d1Backup(d1(name), name, msg.name == null ? "" : str(msg.name)) };
1386
- }
1317
+ case "d1.query":
1318
+ case "d1.batch":
1319
+ case "d1.exec":
1320
+ case "d1.backup":
1321
+ return await d1(requireD1(msg.db), msg);
1387
1322
 
1388
1323
  case "ratelimit.check": {
1389
1324
  // #69 — fixed window; limit/period arrive in the message. Same upsert as
@@ -1640,21 +1575,37 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1640
1575
 
1641
1576
  // #125 — alarms. `at` is epoch ms; setting one replaces any pending alarm
1642
1577
  // for that object, and attempts resets because this is a fresh schedule.
1643
- case "do.alarm.set":
1644
- doDb.query(
1645
- "INSERT INTO do_alarm (cls, id, at, attempts) VALUES (?1,?2,?3,0) " +
1646
- "ON CONFLICT (cls, id) DO UPDATE SET at = ?3, attempts = 0",
1647
- ).run(requireDoClass(msg.cls), str(msg.id), Math.trunc(Number(msg.at) || 0));
1648
- return { ok: true };
1578
+ case "do.alarm.set": {
1579
+ const cls = requireDoClass(msg.cls);
1580
+ doDb.transaction(() => {
1581
+ doDb.query("DELETE FROM do_alarm_delivery WHERE cls=? AND id=? AND expires<=?").run(cls,str(msg.id),Date.now());
1582
+ doDb.query("INSERT INTO do_alarm (cls,id,at,attempts) VALUES (?1,?2,?3,0) ON CONFLICT (cls,id) DO UPDATE SET at=?3,attempts=0").run(cls,str(msg.id),Math.trunc(Number(msg.at)||0));
1583
+ }).immediate();
1584
+ return {ok:true};
1585
+ }
1649
1586
  case "do.alarm.get": {
1650
1587
  const row = doDb
1651
- .query<{ at: number }, [string, string]>("SELECT at FROM do_alarm WHERE cls = ? AND id = ?")
1588
+ .query<{ at: number }, [string, string]>(
1589
+ "SELECT at FROM do_alarm WHERE cls = ? AND id = ?",
1590
+ )
1652
1591
  .get(requireDoClass(msg.cls), str(msg.id));
1653
- return { ok: true, at: row ? row.at : null };
1592
+ const retry = row
1593
+ ? null
1594
+ : doDb
1595
+ .query<{ at: number }, [string, string, number]>(
1596
+ "SELECT at FROM do_alarm_delivery WHERE cls=? AND id=? AND expires <= ? AND attempts < 5",
1597
+ )
1598
+ .get(str(msg.cls), str(msg.id), Date.now() + 5_000);
1599
+ return { ok: true, at: row ? row.at : (retry?.at ?? null) };
1654
1600
  }
1655
1601
  case "do.alarm.delete": {
1656
- const r = doDb.query("DELETE FROM do_alarm WHERE cls = ? AND id = ?").run(requireDoClass(msg.cls), str(msg.id));
1657
- return { ok: true, deleted: r.changes > 0 };
1602
+ const cls = requireDoClass(msg.cls);
1603
+ const deleted = doDb.transaction(() => {
1604
+ const pending = doDb.query("DELETE FROM do_alarm WHERE cls=? AND id=?").run(cls,str(msg.id));
1605
+ const delivery = doDb.query("DELETE FROM do_alarm_delivery WHERE cls=? AND id=?").run(cls,str(msg.id));
1606
+ return pending.changes > 0 || delivery.changes > 0;
1607
+ }).immediate();
1608
+ return {ok:true,deleted};
1658
1609
  }
1659
1610
 
1660
1611
  case "assets.get": {
@@ -1763,6 +1714,11 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1763
1714
  if (msg.op === "assets.get") {
1764
1715
  if (!bindings.assets) throw new Error("assets not bound");
1765
1716
  const reqPath = str(msg.path) || "/";
1717
+ const rules = { headers: assetManifest?.headers ?? [], redirects: assetManifest?.redirects ?? [] };
1718
+ for (const rule of rules.redirects) {
1719
+ const location = matchRedirect(rule, reqPath);
1720
+ if (location !== null) return { reply: { ok: true, found: false, status: rule.status, location, rules } };
1721
+ }
1766
1722
  const key = resolveAssetKey(reqPath, (candidate) => !!assetManifest?.files[candidate]);
1767
1723
  let hit = key ? readAsset(key) : null;
1768
1724
  let found = Boolean(hit);
@@ -1774,9 +1730,9 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1774
1730
  } else if (!hit && assetManifest?.notFound === "404-page") {
1775
1731
  hit = readAsset("/404.html");
1776
1732
  }
1777
- if (!hit) return { reply: { ok: true, found: false, status: 404 }, bytes: new TextEncoder().encode("Not Found") };
1733
+ if (!hit) return { reply: { ok: true, found: false, status: 404, rules }, bytes: new TextEncoder().encode("Not Found") };
1778
1734
  return {
1779
- reply: { ok: true, found, status, type: hit.type, hash: hit.hash },
1735
+ reply: { ok: true, found, status, type: hit.type, hash: hit.hash, rules },
1780
1736
  bytes: hit.body,
1781
1737
  };
1782
1738
  }
@@ -1893,103 +1849,189 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
1893
1849
  const QUEUE_BATCH = 10;
1894
1850
  const QUEUE_MAX_ATTEMPTS = 5;
1895
1851
 
1896
- async function deliverTrigger(kind: "scheduled" | "queue" | "alarm", body: JsonObject): Promise<Response | null> {
1897
- if (!opts.sproutUrl || !dispatchEnabled()) return null;
1852
+ const timeoutMs = opts.deliveryTimeoutMs ?? 25_000;
1853
+ const leaseMs = opts.deliveryLeaseMs ?? 30_000;
1854
+ if (!(timeoutMs > 0 && leaseMs > timeoutMs))
1855
+ throw new Error("delivery lease must exceed its timeout");
1856
+ let closed = false;
1857
+ const deliveries = new Set<AbortController>();
1858
+ async function deliverTrigger(
1859
+ kind: "scheduled" | "queue" | "alarm",
1860
+ body: JsonObject,
1861
+ ): Promise<Response | null> {
1862
+ if (!opts.sproutUrl || !dispatchEnabled() || closed) return null;
1863
+ const abort = new AbortController();
1864
+ deliveries.add(abort);
1865
+ const timer = setTimeout(() => abort.abort(), timeoutMs);
1898
1866
  try {
1899
- return await doFetch(opts.sproutUrl, {
1900
- method: "POST",
1901
- headers: { "x-sb-trigger": kind, "x-sb-token": token, "content-type": "application/json" },
1902
- body: JSON.stringify(body),
1903
- });
1867
+ const response = await Promise.race([
1868
+ doFetch(opts.sproutUrl, {
1869
+ method: "POST",
1870
+ signal: abort.signal,
1871
+ headers: {
1872
+ "x-sb-trigger": kind,
1873
+ "x-sb-token": token,
1874
+ "content-type": "application/json",
1875
+ },
1876
+ body: JSON.stringify(body),
1877
+ }).then(async (res) => {
1878
+ if (kind !== "queue") { await res.body?.cancel(); return new Response(null,{status:res.status}); }
1879
+ const chunks: Uint8Array[] = [];
1880
+ let size = 0;
1881
+ const reader = res.body?.getReader();
1882
+ try {
1883
+ if (reader) for (;;) {
1884
+ const part = await reader.read();
1885
+ if (part.done) break;
1886
+ size += part.value.byteLength;
1887
+ if (size > 65_536) { await reader.cancel(); throw new Error("delivery acknowledgement exceeds 64 KiB"); }
1888
+ chunks.push(part.value);
1889
+ }
1890
+ } finally { reader?.releaseLock(); }
1891
+ return new Response(size ? Buffer.concat(chunks) : null,{status:res.status,headers:res.headers});
1892
+ }),
1893
+ new Promise<never>((_, reject) =>
1894
+ abort.signal.addEventListener("abort", () => reject(new Error("delivery interrupted")), {
1895
+ once: true,
1896
+ }),
1897
+ ),
1898
+ ]);
1899
+ return response;
1904
1900
  } catch {
1905
1901
  return null;
1902
+ } finally {
1903
+ clearTimeout(timer);
1904
+ deliveries.delete(abort);
1906
1905
  }
1907
1906
  }
1908
1907
 
1909
1908
  function drainQueuesOnce(): void {
1910
- if (!opts.sproutUrl || !dispatchEnabled() || bindings.queues.length === 0) return;
1911
- const now = Date.now();
1909
+ if (!opts.sproutUrl || !dispatchEnabled() || closed) return;
1912
1910
  for (const binding of bindings.queues) {
1913
- // `store`/`part` route the rows to this queue's backing file (#74); the
1914
- // trigger payload still carries the binding name the sprout matches on.
1915
1911
  const { store, part } = storeFor("queue", binding);
1912
+ const now = Date.now();
1913
+ const claim = randomBytes(24).toString("hex");
1916
1914
  const rows = store
1917
- .query<{ id: string; body: string; attempts: number }, [string, number, number]>(
1918
- "SELECT id, body, attempts FROM mq WHERE queue = ? AND dead = 0 AND visible_at <= ? ORDER BY visible_at LIMIT ?",
1919
- )
1920
- .all(part, now, QUEUE_BATCH);
1921
- if (rows.length === 0) continue;
1922
- // hide the batch so the next tick doesn't re-deliver it while in flight
1923
- const hideUntil = now + 30_000;
1924
- const hide = store.query("UPDATE mq SET visible_at = ? WHERE id = ?");
1925
- for (const r of rows) hide.run(hideUntil, r.id);
1926
-
1915
+ .transaction(() => {
1916
+ store
1917
+ .query("UPDATE mq SET dead = 1 WHERE queue = ? AND visible_at <= ? AND attempts >= ?")
1918
+ .run(part, now, QUEUE_MAX_ATTEMPTS);
1919
+ const due = store
1920
+ .query<{ id: string; body: string; attempts: number }, [string, number, number]>(
1921
+ "SELECT id, body, attempts FROM mq WHERE queue = ? AND dead = 0 AND visible_at <= ? ORDER BY visible_at LIMIT ?",
1922
+ )
1923
+ .all(part, now, QUEUE_BATCH);
1924
+ for (const row of due)
1925
+ store
1926
+ .query(
1927
+ "UPDATE mq SET visible_at = ?, claim_token = ?, attempts = attempts + 1 WHERE queue = ? AND id = ?",
1928
+ )
1929
+ .run(now + leaseMs, claim, part, row.id);
1930
+ return due;
1931
+ })
1932
+ .immediate();
1933
+ if (!rows.length) continue;
1927
1934
  void deliverTrigger("queue", {
1928
1935
  queue: binding,
1929
- messages: rows.map((r) => ({ id: r.id, body: r.body, timestamp: now, attempts: r.attempts + 1 })),
1936
+ messages: rows.map((r) => ({
1937
+ id: r.id,
1938
+ body: r.body,
1939
+ timestamp: now,
1940
+ attempts: r.attempts + 1,
1941
+ })),
1930
1942
  }).then(async (res) => {
1931
- let ack: string[] = rows.map((r) => r.id); // default: ack all if the sprout did not say
1943
+ if (closed || !dispatchEnabled()) return;
1944
+ const claimed = new Set(rows.map((r) => r.id));
1945
+ let ack = rows.map((r) => r.id);
1932
1946
  let retry: string[] = [];
1933
- if (res && res.ok) {
1947
+ if (res?.ok) {
1934
1948
  try {
1935
1949
  const parsed = jsonObject(parseJsonValue(await res.text()));
1936
- const ids = (value: JsonValue | undefined) => (Array.isArray(value) ? value.filter(isString) : null);
1950
+ const ids = (v: JsonValue | undefined) =>
1951
+ Array.isArray(v) ? v.filter(isString).filter((id) => claimed.has(id)) : null;
1937
1952
  ack = (parsed && ids(parsed.ack)) ?? ack;
1938
1953
  retry = (parsed && ids(parsed.retry)) ?? [];
1939
1954
  } catch {
1940
- /* keep defaults */
1955
+ ack = [];
1956
+ retry = rows.map((r) => r.id);
1941
1957
  }
1942
1958
  } else {
1943
1959
  ack = [];
1944
- retry = rows.map((r) => r.id); // delivery failed → retry all
1960
+ retry = rows.map((r) => r.id);
1945
1961
  }
1946
- const del = store.query("DELETE FROM mq WHERE id = ?");
1947
- for (const id of ack) del.run(id);
1948
- const bump = store.query(
1949
- "UPDATE mq SET attempts = attempts + 1, visible_at = ?, dead = CASE WHEN attempts + 1 >= ? THEN 1 ELSE 0 END WHERE id = ?",
1950
- );
1951
- for (const id of retry) bump.run(Date.now() + 5_000, QUEUE_MAX_ATTEMPTS, id);
1962
+ if (closed || !dispatchEnabled()) return;
1963
+ const retrySet = new Set(retry);
1964
+ store
1965
+ .transaction(() => {
1966
+ for (const id of ack)
1967
+ if (!retrySet.has(id))
1968
+ store
1969
+ .query(
1970
+ "DELETE FROM mq WHERE queue = ? AND id = ? AND claim_token = ? AND visible_at > ?",
1971
+ )
1972
+ .run(part, id, claim, Date.now());
1973
+ for (const id of retry)
1974
+ store
1975
+ .query(
1976
+ "UPDATE mq SET visible_at = ?, claim_token = '', dead = CASE WHEN attempts >= ? THEN 1 ELSE 0 END WHERE queue = ? AND id = ? AND claim_token = ? AND visible_at > ?",
1977
+ )
1978
+ .run(Date.now() + 5_000, QUEUE_MAX_ATTEMPTS, part, id, claim, Date.now());
1979
+ })
1980
+ .immediate();
1952
1981
  });
1953
1982
  }
1954
1983
  }
1955
1984
 
1956
1985
  const ALARM_MAX_ATTEMPTS = 5;
1957
-
1958
- /**
1959
- * #125 — fire alarms that are due.
1960
- *
1961
- * Claim-then-deliver, not deliver-then-delete: `alarm()` is allowed to call
1962
- * `setAlarm()` for its next run, and that write lands *during* delivery. If
1963
- * we deleted afterwards we would erase the alarm the handler just scheduled,
1964
- * which is how a self-rescheduling object silently stops. Deleting first
1965
- * means the handler's row is a fresh insert nothing else touches.
1966
- *
1967
- * A failed delivery re-arms with the queue consumer's policy — 5s apart, five
1968
- * attempts — and `DO NOTHING` so a re-arm never overwrites an alarm the
1969
- * handler set itself.
1970
- */
1971
1986
  function fireAlarmsOnce(): void {
1972
- if (!opts.sproutUrl || !dispatchEnabled() || bindings.do.length === 0) return;
1987
+ if (!opts.sproutUrl || !dispatchEnabled() || bindings.do.length === 0 || closed) return;
1973
1988
  const now = Date.now();
1974
- const due = doDb
1975
- .query<{ cls: string; id: string; at: number; attempts: number }, [number]>(
1976
- "SELECT cls, id, at, attempts FROM do_alarm WHERE at <= ? ORDER BY at LIMIT 10",
1977
- )
1978
- .all(now);
1979
- if (due.length === 0) return;
1980
- const claim = doDb.query("DELETE FROM do_alarm WHERE cls = ? AND id = ?");
1981
- const rearm = doDb.query(
1982
- "INSERT INTO do_alarm (cls, id, at, attempts) VALUES (?1,?2,?3,?4) ON CONFLICT (cls, id) DO NOTHING",
1983
- );
1984
- for (const row of due) {
1985
- claim.run(row.cls, row.id);
1986
- void deliverTrigger("alarm", { cls: row.cls, id: row.id, scheduledTime: row.at }).then((res) => {
1987
- if (res && res.ok) return;
1988
- const attempts = row.attempts + 1;
1989
- if (attempts >= ALARM_MAX_ATTEMPTS) return; // give up rather than spin forever
1990
- rearm.run(row.cls, row.id, Date.now() + 5_000, attempts);
1989
+ const claimed = doDb
1990
+ .transaction(() => {
1991
+ doDb.query("DELETE FROM do_alarm_delivery WHERE expires<=? AND attempts>=5 AND EXISTS (SELECT 1 FROM do_alarm a WHERE a.cls=do_alarm_delivery.cls AND a.id=do_alarm_delivery.id)").run(now);
1992
+ const due = doDb
1993
+ .query<{ cls: string; id: string; at: number; attempts: number }, [number]>(
1994
+ "SELECT cls,id,at,attempts FROM do_alarm WHERE at <= ? AND NOT EXISTS (SELECT 1 FROM do_alarm_delivery d WHERE d.cls=do_alarm.cls AND d.id=do_alarm.id) ORDER BY at LIMIT 10",
1995
+ )
1996
+ .all(now)
1997
+ .filter((r) => bindings.do.some((b) => b.className === r.cls));
1998
+ for (const r of due) {
1999
+ doDb
2000
+ .query("INSERT INTO do_alarm_delivery VALUES (?,?,?,?,?,?)")
2001
+ .run(r.cls, r.id, r.at, r.attempts, "", 0);
2002
+ doDb.query("DELETE FROM do_alarm WHERE cls=? AND id=? AND at=?").run(r.cls, r.id, r.at);
2003
+ }
2004
+ const expired = doDb
2005
+ .query<{ cls: string; id: string; at: number; attempts: number }, [number, number]>(
2006
+ "SELECT cls,id,at,attempts FROM do_alarm_delivery WHERE expires <= ? AND attempts < ? LIMIT 10",
2007
+ )
2008
+ .all(now, ALARM_MAX_ATTEMPTS)
2009
+ .filter((r) => bindings.do.some((b) => b.className === r.cls));
2010
+ return expired.map((r) => {
2011
+ const claim = randomBytes(24).toString("hex");
2012
+ doDb
2013
+ .query(
2014
+ "UPDATE do_alarm_delivery SET token=?,expires=?,attempts=attempts+1 WHERE cls=? AND id=?",
2015
+ )
2016
+ .run(claim, now + leaseMs, r.cls, r.id);
2017
+ return { ...r, claim };
2018
+ });
2019
+ })
2020
+ .immediate();
2021
+ for (const r of claimed)
2022
+ void deliverTrigger("alarm", { cls: r.cls, id: r.id, scheduledTime: r.at }).then((res) => {
2023
+ if (closed || !dispatchEnabled()) return;
2024
+ if (res?.ok || doDb.query("SELECT 1 FROM do_alarm WHERE cls=? AND id=?").get(r.cls, r.id))
2025
+ doDb
2026
+ .query("DELETE FROM do_alarm_delivery WHERE cls=? AND id=? AND token=? AND expires > ?")
2027
+ .run(r.cls, r.id, r.claim, Date.now());
2028
+ else
2029
+ doDb
2030
+ .query(
2031
+ "UPDATE do_alarm_delivery SET expires=? WHERE cls=? AND id=? AND token=? AND expires > ?",
2032
+ )
2033
+ .run(Date.now() + 5_000, r.cls, r.id, r.claim, Date.now());
1991
2034
  });
1992
- }
1993
2035
  }
1994
2036
 
1995
2037
  if (opts.sproutUrl) {
@@ -2005,7 +2047,8 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
2005
2047
  if (stamp === lastTick) return; // once per minute
2006
2048
  lastTick = stamp;
2007
2049
  for (const expr of bindings.crons) {
2008
- if (cronMatches(expr, now)) void deliverTrigger("scheduled", { cron: expr, scheduledTime: now.getTime() });
2050
+ if (cronMatches(expr, now))
2051
+ void deliverTrigger("scheduled", { cron: expr, scheduledTime: now.getTime() });
2009
2052
  }
2010
2053
  }, 15_000),
2011
2054
  );
@@ -2018,8 +2061,11 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
2018
2061
  handleFrame,
2019
2062
  transfer,
2020
2063
  close: () => {
2064
+ closed = true;
2065
+ for (const delivery of deliveries) delivery.abort();
2021
2066
  for (const t of timers) clearInterval(t);
2022
- for (const conn of d1Conns.values()) conn.close();
2067
+ d1Session?.close();
2068
+ d1Store.close();
2023
2069
  for (const conn of resourceDbs.values()) conn.close();
2024
2070
  if (doDb !== db) doDb.close();
2025
2071
  db.close();
@@ -2070,18 +2116,63 @@ export function listen(broker: Broker, hostname: string, port: number): BrokerSe
2070
2116
  // Concatenating on every chunk copies the accumulated buffer each time, which
2071
2117
  // is quadratic in the body size: an 8 MB object arriving in 64 KB pieces
2072
2118
  // copied hundreds of megabytes before anything was parsed.
2073
- const server = Bun.listen<{ chunks: Buffer[]; size: number }>({
2119
+ type SocketState = {
2120
+ chunks: Buffer[];
2121
+ size: number;
2122
+ processing: boolean;
2123
+ closed: boolean;
2124
+ output: Buffer | null;
2125
+ offset: number;
2126
+ written: (() => void) | null;
2127
+ };
2128
+ const flush = (socket: Bun.Socket<SocketState>): void => {
2129
+ const state = socket.data;
2130
+ if (state.closed || !state.output) return;
2131
+ const accepted = socket.write(state.output.subarray(state.offset));
2132
+ if (accepted < 0) { socket.end(); return; }
2133
+ state.offset += accepted;
2134
+ if (state.offset === state.output.length) {
2135
+ state.output = null;
2136
+ const done = state.written;
2137
+ state.written = null;
2138
+ done?.();
2139
+ }
2140
+ };
2141
+ const send = (socket: Bun.Socket<SocketState>, output: Buffer): Promise<void> => {
2142
+ if (socket.data.closed) return Promise.resolve();
2143
+ return new Promise((done) => {
2144
+ socket.data.output = output;
2145
+ socket.data.offset = 0;
2146
+ socket.data.written = done;
2147
+ flush(socket);
2148
+ });
2149
+ };
2150
+ const server = Bun.listen<SocketState>({
2074
2151
  hostname,
2075
2152
  port,
2076
2153
  socket: {
2077
2154
  open(socket) {
2078
- socket.data = { chunks: [], size: 0 };
2155
+ socket.data = { chunks: [], size: 0, processing: false, closed: false, output: null, offset: 0, written: null };
2156
+ },
2157
+ drain: flush,
2158
+ close(socket) {
2159
+ const state = socket.data;
2160
+ state.closed = true;
2161
+ state.chunks = [];
2162
+ state.size = 0;
2163
+ state.output = null;
2164
+ const done = state.written;
2165
+ state.written = null;
2166
+ done?.();
2079
2167
  },
2080
2168
  async data(socket, chunk) {
2081
2169
  const state = socket.data;
2082
2170
  state.chunks.push(chunk);
2083
2171
  state.size += chunk.length;
2084
- for (;;) {
2172
+ if (state.processing) return;
2173
+ state.processing = true;
2174
+ try { for (;;) {
2175
+ if (state.closed) return;
2085
2176
  if (state.size < 4) return;
2086
2177
  if (state.chunks.length > 1) {
2087
2178
  state.chunks = [Buffer.concat(state.chunks, state.size)];
@@ -2089,7 +2180,7 @@ export function listen(broker: Broker, hostname: string, port: number): BrokerSe
2089
2180
  const buf = state.chunks[0];
2090
2181
  const len = buf.readUInt32LE(0);
2091
2182
  if (len > MAX_FRAME) {
2092
- socket.write(encodeFrame({ ok: false, error: "frame too large" }));
2183
+ await send(socket, encodeFrame({ ok: false, error: "frame too large" }));
2093
2184
  socket.end();
2094
2185
  return;
2095
2186
  }
@@ -2100,8 +2191,10 @@ export function listen(broker: Broker, hostname: string, port: number): BrokerSe
2100
2191
  const rest = buf.subarray(4 + len);
2101
2192
  state.chunks = rest.length ? [Buffer.from(rest)] : [];
2102
2193
  state.size = rest.length;
2103
- socket.write(await broker.handleFrame(payload));
2104
- }
2194
+ // Bun does not buffer unwritten bytes. Serialize replies and retain
2195
+ // the unsent suffix until drain, including binary R2 responses.
2196
+ await send(socket, await broker.handleFrame(payload));
2197
+ } } finally { state.processing = false; }
2105
2198
  },
2106
2199
  },
2107
2200
  });
@@ -2119,8 +2212,9 @@ export function listenTransfers(broker: Broker, hostname: string, port: number):
2119
2212
  return { port: server.port ?? port, stop: () => server.stop(true) };
2120
2213
  }
2121
2214
 
2122
- if (import.meta.main) {
2215
+ export function startBroker(argv: string[], d1Pool?: D1Pool) {
2123
2216
  const { values } = parseArgs({
2217
+ args: argv,
2124
2218
  options: {
2125
2219
  port: { type: "string" },
2126
2220
  "transfer-port": { type: "string" },
@@ -2160,7 +2254,9 @@ if (import.meta.main) {
2160
2254
  const parsed = jsonObject(parseJsonValue(values.services));
2161
2255
  if (parsed) {
2162
2256
  services = Object.fromEntries(
2163
- Object.entries(parsed).flatMap(([binding, host]) => (isString(host) ? [[binding, host] as const] : [])),
2257
+ Object.entries(parsed).flatMap(([binding, host]) =>
2258
+ isString(host) ? [[binding, host] as const] : [],
2259
+ ),
2164
2260
  );
2165
2261
  }
2166
2262
  } catch {
@@ -2171,21 +2267,20 @@ if (import.meta.main) {
2171
2267
  // supervisor promotes one only after the edge points at it, using a local
2172
2268
  // signal instead of a network-visible control endpoint.
2173
2269
  let dispatchEnabled = !values["dispatch-disabled"];
2174
- process.on("SIGUSR1", () => {
2175
- dispatchEnabled = true;
2176
- });
2177
- process.on("SIGUSR2", () => {
2178
- dispatchEnabled = false;
2179
- });
2180
2270
  const broker = createBroker({
2271
+ d1Pool,
2181
2272
  db: values.db,
2182
2273
  dataDir: values["data-dir"],
2183
2274
  resourceDir: values["resource-dir"],
2184
2275
  doDb: values["do-db"],
2185
2276
  ownerId: values["owner-id"],
2186
- r2QuotaBytes: Number(values["r2-quota-bytes"] ?? process.env.SPROUTBOAT_R2_QUOTA_BYTES) || undefined,
2187
- r2ResourceIds: values["r2-resource-ids"] ? String(values["r2-resource-ids"]).split(",").filter(Boolean) : undefined,
2188
- r2MinFreeBytes: Number(values["r2-min-free-bytes"] ?? process.env.SPROUTBOAT_R2_MIN_FREE_BYTES) || undefined,
2277
+ r2QuotaBytes:
2278
+ Number(values["r2-quota-bytes"] ?? process.env.SPROUTBOAT_R2_QUOTA_BYTES) || undefined,
2279
+ r2ResourceIds: values["r2-resource-ids"]
2280
+ ? String(values["r2-resource-ids"]).split(",").filter(Boolean)
2281
+ : undefined,
2282
+ r2MinFreeBytes:
2283
+ Number(values["r2-min-free-bytes"] ?? process.env.SPROUTBOAT_R2_MIN_FREE_BYTES) || undefined,
2189
2284
  token: values.token ?? process.env.SB_BROKER_TOKEN,
2190
2285
  bindings,
2191
2286
  secrets,
@@ -2195,10 +2290,42 @@ if (import.meta.main) {
2195
2290
  services,
2196
2291
  dispatchEnabled: () => dispatchEnabled,
2197
2292
  });
2198
- const { port } = listen(broker, "127.0.0.1", Number(values.port ?? process.env.SB_BROKER_PORT ?? 0));
2293
+ let listener: BrokerServer;
2294
+ try {
2295
+ listener = listen(broker, "127.0.0.1", Number(values.port ?? process.env.SB_BROKER_PORT ?? 0));
2296
+ } catch (error) {
2297
+ broker.close();
2298
+ throw error;
2299
+ }
2300
+ const { port } = listener;
2199
2301
  const transferPort = Number(values["transfer-port"] ?? process.env.SB_R2_TRANSFER_PORT ?? 0);
2200
- const transferServer = transferPort > 0 ? listenTransfers(broker, "127.0.0.1", transferPort) : null;
2201
- console.log(
2202
- `sproutboat broker: 127.0.0.1:${port} transfer=${transferServer?.port ?? "off"} db=${values.db ?? ":memory:"} sprout=${values["sprout-url"] ?? process.env.SB_SPROUT_URL ?? "(none)"} dispatch=${dispatchEnabled ? "enabled" : "disabled"}`,
2203
- );
2302
+ let transferServer: BrokerServer | null = null;
2303
+ try {
2304
+ transferServer = transferPort > 0 ? listenTransfers(broker, "127.0.0.1", transferPort) : null;
2305
+ } catch (error) {
2306
+ listener.stop();
2307
+ broker.close();
2308
+ throw error;
2309
+ }
2310
+ return {
2311
+ port,
2312
+ enableDispatch: () => {
2313
+ dispatchEnabled = true;
2314
+ },
2315
+ disableDispatch: () => {
2316
+ dispatchEnabled = false;
2317
+ },
2318
+ close: () => {
2319
+ listener.stop();
2320
+ transferServer?.stop();
2321
+ broker.close();
2322
+ },
2323
+ };
2324
+ }
2325
+
2326
+ if (import.meta.main) {
2327
+ const session = startBroker(process.argv.slice(2));
2328
+ process.on("SIGUSR1", session.enableDispatch);
2329
+ process.on("SIGUSR2", session.disableDispatch);
2330
+ console.log(`sproutboat broker: 127.0.0.1:${session.port}`);
2204
2331
  }