sproutboat 0.2.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 ADDED
@@ -0,0 +1,593 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Per-deployment binding broker.
4
+ *
5
+ * A native-fetch worker has no syscalls of its own beyond an inbound HTTP
6
+ * server; the prelude's inline-C transport opens a loopback TCP connection to
7
+ * this process for every `env.<KV>` / `env.<SECRET>` / `fetch()` call. The
8
+ * supervisor starts one broker per deployment, on its own loopback port, and
9
+ * passes `SB_BROKER_PORT` + `SB_BROKER_TOKEN` to the worker next to `$PORT`.
10
+ *
11
+ * Wire frame (both directions): [u32 LE length][payload].
12
+ * request payload : "<token>\n<json>"
13
+ * reply payload : "<json>"
14
+ *
15
+ * ponytail: one SQLite file for KV, secrets from a plain JSON file, fetch
16
+ * allowlisted by exact host. Encryption at rest, per-key TTLs, redirect
17
+ * re-validation and private-IP blocking are v2 — the exact-host allowlist is
18
+ * the only SSRF control today.
19
+ */
20
+ import { Database } from "bun:sqlite";
21
+ import { createHash } from "node:crypto";
22
+ import { existsSync, mkdirSync, readFileSync } from "node:fs";
23
+ import { dirname, join, normalize, resolve } from "node:path";
24
+ import { parseArgs } from "node:util";
25
+ import type { AssetManifest } from "./assets";
26
+
27
+ export type Bindings = {
28
+ kv: string[];
29
+ secrets: string[];
30
+ outbound: string[];
31
+ d1: string[];
32
+ r2: string[];
33
+ queues: string[];
34
+ analytics: string[];
35
+ do: Array<{ binding: string; className: string }>;
36
+ crons: string[];
37
+ /** Static-asset binding name, or `""` when assets are edge-only. */
38
+ assets: string;
39
+ };
40
+ export type Frame = Record<string, unknown>;
41
+
42
+ export type BrokerOptions = {
43
+ db?: string;
44
+ /** Directory for per-D1-binding SQLite files. Defaults to `<dirname(db)>/d1`, or in-memory when `db` is `:memory:`. */
45
+ dataDir?: string;
46
+ token?: string;
47
+ bindings?: Partial<Bindings>;
48
+ secrets?: Record<string, string>;
49
+ /**
50
+ * `http://127.0.0.1:<PORT>` of this deployment's worker. When set, the broker
51
+ * runs the cron scheduler and the queue consumer, delivering to the worker
52
+ * with an `x-sb-trigger` header authenticated by `token`.
53
+ */
54
+ workerUrl?: string;
55
+ /** Directory of published static assets (its sibling `assets.json` is the manifest). Backs `assets.get`. */
56
+ assetsDir?: string;
57
+ /** Injected in tests; defaults to the global `fetch`. */
58
+ fetchImpl?: typeof fetch;
59
+ };
60
+
61
+ type SqlParam = string | number | null;
62
+ type R2Row = { key: string; body: string; size: number; etag: string; uploaded: string; http_json: string; custom_json: string };
63
+ const sqlParams = (v: unknown): SqlParam[] => {
64
+ if (!Array.isArray(v)) return [];
65
+ return v.map((p) => (p === null || typeof p === "number" || typeof p === "string" ? p : typeof p === "boolean" ? (p ? 1 : 0) : String(p)));
66
+ };
67
+
68
+ // One binding call = one frame, and an R2/KV value travels inside it as a JSON
69
+ // string (escaping can inflate binary content several ×). 32 MiB keeps a ~25 MB
70
+ // upload working; true large-object streaming is v2.
71
+ const MAX_FRAME = 32 * 1024 * 1024;
72
+ const str = (v: unknown): string => (typeof v === "string" ? v : String(v ?? ""));
73
+
74
+ export type Broker = {
75
+ /** Run one parsed request object through the op dispatch. */
76
+ dispatch(msg: Frame): Promise<Frame>;
77
+ /** Verify the token line and dispatch a raw "<token>\n<json>" payload. */
78
+ handlePayload(payload: string): Promise<Frame>;
79
+ close(): void;
80
+ };
81
+
82
+ export function createBroker(opts: BrokerOptions = {}): Broker {
83
+ const bindings: Bindings = { kv: [], secrets: [], outbound: [], d1: [], r2: [], queues: [], analytics: [], do: [], crons: [], assets: "", ...opts.bindings };
84
+ const secrets = opts.secrets ?? {};
85
+
86
+ // Static assets: read the manifest once. Files are read from disk per request
87
+ // (small, OS-cached); no need to hold bodies in memory.
88
+ const assetsDir = opts.assetsDir ? resolve(opts.assetsDir) : null;
89
+ let assetManifest: AssetManifest | null = null;
90
+ if (assetsDir) {
91
+ const manifestPath = join(dirname(assetsDir), "assets.json");
92
+ if (existsSync(manifestPath)) assetManifest = JSON.parse(readFileSync(manifestPath, "utf8")) as AssetManifest;
93
+ }
94
+ const readAsset = (path: string): { type: string; hash: string; body: string } | null => {
95
+ if (!assetsDir || !assetManifest) return null;
96
+ const entry = assetManifest.files[path];
97
+ if (!entry) return null;
98
+ // path keys are `/`-prefixed and `..`-free (walkAssets), but re-check.
99
+ const abs = normalize(join(assetsDir, path));
100
+ if (!abs.startsWith(assetsDir)) return null;
101
+ return { type: entry.type, hash: entry.hash, body: readFileSync(abs, "utf8") };
102
+ };
103
+ const token = opts.token ?? "";
104
+ const doFetch = opts.fetchImpl ?? fetch;
105
+
106
+ const dbPath = opts.db ?? ":memory:";
107
+ const inMemory = dbPath === ":memory:" || dbPath === "";
108
+ const d1Dir = opts.dataDir ?? (inMemory ? null : join(dirname(resolve(dbPath)), "d1"));
109
+
110
+ const db = new Database(dbPath);
111
+ db.exec("PRAGMA journal_mode = WAL");
112
+ db.exec("CREATE TABLE IF NOT EXISTS kv (ns TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (ns, key))");
113
+ db.exec(
114
+ "CREATE TABLE IF NOT EXISTS r2 (bucket TEXT NOT NULL, key TEXT NOT NULL, body TEXT NOT NULL, size INTEGER NOT NULL, " +
115
+ "etag TEXT NOT NULL, uploaded TEXT NOT NULL, http_json TEXT NOT NULL DEFAULT '{}', custom_json TEXT NOT NULL DEFAULT '{}', " +
116
+ "PRIMARY KEY (bucket, key))",
117
+ );
118
+ db.exec(
119
+ "CREATE TABLE IF NOT EXISTS mq (queue TEXT NOT NULL, id TEXT PRIMARY KEY, body TEXT NOT NULL, " +
120
+ "visible_at INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, dead INTEGER NOT NULL DEFAULT 0)",
121
+ );
122
+ db.exec(
123
+ "CREATE TABLE IF NOT EXISTS do_storage (cls TEXT NOT NULL, id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, " +
124
+ "PRIMARY KEY (cls, id, key))",
125
+ );
126
+ db.exec(
127
+ "CREATE TABLE IF NOT EXISTS ae (dataset TEXT NOT NULL, ts INTEGER NOT NULL, indexes_json TEXT NOT NULL, " +
128
+ "blobs_json TEXT NOT NULL, doubles_json TEXT NOT NULL)",
129
+ );
130
+ const kvGet = db.query<{ value: string }, [string, string]>("SELECT value FROM kv WHERE ns = ? AND key = ?");
131
+ const kvPut = db.query("INSERT INTO kv (ns, key, value) VALUES (?1, ?2, ?3) ON CONFLICT (ns, key) DO UPDATE SET value = ?3");
132
+ const kvDel = db.query("DELETE FROM kv WHERE ns = ? AND key = ?");
133
+ const kvList = db.query<{ key: string }, [string, string]>("SELECT key FROM kv WHERE ns = ? AND key LIKE ? || '%' ORDER BY key LIMIT 1000");
134
+
135
+ const bound = (list: string[], kind: string) => (name: unknown): string => {
136
+ const n = str(name);
137
+ if (!list.includes(n)) throw new Error(`${kind} not bound: ${n}`);
138
+ return n;
139
+ };
140
+ const requireKv = bound(bindings.kv, "KV namespace");
141
+ const requireD1 = bound(bindings.d1, "D1 database");
142
+ const requireR2 = bound(bindings.r2, "R2 bucket");
143
+ const requireQueue = bound(bindings.queues, "queue");
144
+ const requireAe = bound(bindings.analytics, "analytics dataset");
145
+ const doClasses = new Set(bindings.do.map((d) => d.className));
146
+ const requireDoClass = (cls: unknown): string => {
147
+ const n = str(cls);
148
+ if (!doClasses.has(n)) throw new Error(`Durable Object class not bound: ${n}`);
149
+ return n;
150
+ };
151
+ const newId = () => createHash("sha256").update(`${Date.now()}:${Math.random()}`).digest("hex").slice(0, 24);
152
+
153
+ // One SQLite database per bound D1 name — they are independent SQL namespaces.
154
+ const d1Conns = new Map<string, Database>();
155
+ const d1 = (name: string): Database => {
156
+ let conn = d1Conns.get(name);
157
+ if (!conn) {
158
+ if (d1Dir) mkdirSync(d1Dir, { recursive: true });
159
+ conn = new Database(d1Dir ? join(d1Dir, `${name}.sqlite`) : ":memory:", { create: true });
160
+ conn.exec("PRAGMA journal_mode = WAL");
161
+ d1Conns.set(name, conn);
162
+ }
163
+ return conn;
164
+ };
165
+
166
+ // Run one statement, return CF-D1-shaped { results, meta }.
167
+ const d1Run = (conn: Database, sql: string, params: SqlParam[]): { results: unknown[]; meta: Record<string, unknown> } => {
168
+ const started = performance.now();
169
+ const results = conn.query(sql).all(...params);
170
+ const m = conn.query<{ changes: number; last_row_id: number }, []>(
171
+ "SELECT changes() AS changes, last_insert_rowid() AS last_row_id",
172
+ ).get();
173
+ return {
174
+ results,
175
+ meta: { duration: performance.now() - started, changes: m?.changes ?? 0, last_row_id: m?.last_row_id ?? 0, rows_read: results.length },
176
+ };
177
+ };
178
+
179
+ const r2Row = (r: R2Row) => ({
180
+ key: r.key,
181
+ size: r.size,
182
+ etag: r.etag,
183
+ uploaded: r.uploaded,
184
+ httpMetadata: JSON.parse(r.http_json) as unknown,
185
+ customMetadata: JSON.parse(r.custom_json) as unknown,
186
+ });
187
+
188
+ async function proxyFetch(msg: Frame): Promise<Frame> {
189
+ let url: URL;
190
+ try {
191
+ url = new URL(str(msg.url));
192
+ } catch {
193
+ throw new Error(`invalid url: ${str(msg.url)}`);
194
+ }
195
+ if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error(`unsupported protocol: ${url.protocol}`);
196
+ if (!bindings.outbound.includes(url.host)) throw new Error(`host not in outbound allowlist: ${url.host}`);
197
+
198
+ const headers = new Headers();
199
+ if (Array.isArray(msg.headers)) {
200
+ for (const pair of msg.headers as unknown[]) {
201
+ if (Array.isArray(pair) && pair.length === 2) headers.set(str(pair[0]), str(pair[1]));
202
+ }
203
+ }
204
+ const method = str(msg.method || "GET").toUpperCase();
205
+ const res = await doFetch(url, {
206
+ method,
207
+ headers,
208
+ body: msg.body == null || method === "GET" || method === "HEAD" ? undefined : str(msg.body),
209
+ redirect: "manual",
210
+ });
211
+ const outHeaders: Array<[string, string]> = [];
212
+ res.headers.forEach((v, k) => outHeaders.push([k, v]));
213
+ return { ok: true, status: res.status, headers: outHeaders, body: await res.text() };
214
+ }
215
+
216
+ async function dispatch(msg: Frame): Promise<Frame> {
217
+ switch (msg.op) {
218
+ case "ping":
219
+ return { ok: true, op: "pong", echo: msg.msg ?? null, pid: process.pid };
220
+ case "kv.get": {
221
+ const row = kvGet.get(requireKv(msg.ns), str(msg.key));
222
+ return row ? { ok: true, found: true, value: row.value } : { ok: true, found: false, value: null };
223
+ }
224
+ case "kv.put":
225
+ kvPut.run(requireKv(msg.ns), str(msg.key), str(msg.value));
226
+ return { ok: true };
227
+ case "kv.delete":
228
+ kvDel.run(requireKv(msg.ns), str(msg.key));
229
+ return { ok: true };
230
+ case "kv.list":
231
+ return { ok: true, keys: kvList.all(requireKv(msg.ns), str(msg.prefix)).map((r) => r.key) };
232
+ case "secret.get": {
233
+ const name = str(msg.name);
234
+ if (!bindings.secrets.includes(name)) throw new Error(`secret not bound: ${name}`);
235
+ if (!(name in secrets)) throw new Error(`secret has no value: ${name}`);
236
+ return { ok: true, value: secrets[name] };
237
+ }
238
+ case "fetch":
239
+ return proxyFetch(msg);
240
+
241
+ case "d1.query": {
242
+ const conn = d1(requireD1(msg.db));
243
+ return { ok: true, ...d1Run(conn, str(msg.sql), sqlParams(msg.params)) };
244
+ }
245
+ case "d1.batch": {
246
+ const conn = d1(requireD1(msg.db));
247
+ const stmts = Array.isArray(msg.statements) ? (msg.statements as Frame[]) : [];
248
+ const runAll = conn.transaction(() => stmts.map((s) => d1Run(conn, str(s.sql), sqlParams(s.params))));
249
+ return { ok: true, results: runAll() };
250
+ }
251
+ case "d1.exec": {
252
+ const conn = d1(requireD1(msg.db));
253
+ conn.exec(str(msg.sql));
254
+ return { ok: true };
255
+ }
256
+
257
+ case "r2.put": {
258
+ const bucket = requireR2(msg.bucket);
259
+ const body = str(msg.body);
260
+ const etag = createHash("sha256").update(body).digest("hex");
261
+ db.query(
262
+ "INSERT INTO r2 (bucket, key, body, size, etag, uploaded, http_json, custom_json) VALUES (?1,?2,?3,?4,?5,?6,?7,?8) " +
263
+ "ON CONFLICT (bucket, key) DO UPDATE SET body=?3, size=?4, etag=?5, uploaded=?6, http_json=?7, custom_json=?8",
264
+ ).run(
265
+ bucket,
266
+ str(msg.key),
267
+ body,
268
+ Buffer.byteLength(body),
269
+ etag,
270
+ new Date().toISOString(),
271
+ JSON.stringify(msg.httpMetadata ?? {}),
272
+ JSON.stringify(msg.customMetadata ?? {}),
273
+ );
274
+ return { ok: true, object: { key: str(msg.key), size: Buffer.byteLength(body), etag, uploaded: new Date().toISOString() } };
275
+ }
276
+ case "r2.get":
277
+ case "r2.head": {
278
+ const bucket = requireR2(msg.bucket);
279
+ const row = db.query<R2Row, [string, string]>("SELECT * FROM r2 WHERE bucket = ? AND key = ?").get(bucket, str(msg.key));
280
+ if (!row) return { ok: true, found: false };
281
+ return { ok: true, found: true, object: r2Row(row), body: msg.op === "r2.get" ? row.body : undefined };
282
+ }
283
+ case "r2.delete": {
284
+ const bucket = requireR2(msg.bucket);
285
+ db.query("DELETE FROM r2 WHERE bucket = ? AND key = ?").run(bucket, str(msg.key));
286
+ return { ok: true };
287
+ }
288
+ case "r2.list": {
289
+ const bucket = requireR2(msg.bucket);
290
+ const prefix = str(msg.prefix);
291
+ const cursor = str(msg.cursor);
292
+ const limit = Math.min(Math.max(Number(msg.limit) || 1000, 1), 1000);
293
+ const rows = db.query<R2Row, [string, string, string]>(
294
+ "SELECT * FROM r2 WHERE bucket = ? AND key LIKE ? || '%' AND key > ? ORDER BY key LIMIT " + (limit + 1),
295
+ ).all(bucket, prefix, cursor);
296
+ const truncated = rows.length > limit;
297
+ const page = truncated ? rows.slice(0, limit) : rows;
298
+ return {
299
+ ok: true,
300
+ objects: page.map(r2Row),
301
+ truncated,
302
+ cursor: truncated ? page[page.length - 1].key : null,
303
+ };
304
+ }
305
+
306
+ case "queue.send": {
307
+ const q = requireQueue(msg.queue);
308
+ const at = Date.now() + Math.max(0, Number(msg.delaySeconds) || 0) * 1000;
309
+ db.query("INSERT INTO mq (queue, id, body, visible_at) VALUES (?, ?, ?, ?)").run(q, newId(), str(msg.body), at);
310
+ return { ok: true };
311
+ }
312
+ case "queue.send_batch": {
313
+ const q = requireQueue(msg.queue);
314
+ const msgs = Array.isArray(msg.messages) ? (msg.messages as Frame[]) : [];
315
+ const ins = db.query("INSERT INTO mq (queue, id, body, visible_at) VALUES (?, ?, ?, ?)");
316
+ db.transaction(() => {
317
+ for (const m of msgs) ins.run(q, newId(), str(m.body), Date.now() + Math.max(0, Number(m.delaySeconds) || 0) * 1000);
318
+ })();
319
+ return { ok: true, count: msgs.length };
320
+ }
321
+
322
+ case "ae.write": {
323
+ const ds = requireAe(msg.dataset);
324
+ db.query("INSERT INTO ae (dataset, ts, indexes_json, blobs_json, doubles_json) VALUES (?, ?, ?, ?, ?)").run(
325
+ ds,
326
+ Date.now(),
327
+ JSON.stringify(msg.indexes ?? []),
328
+ JSON.stringify(msg.blobs ?? []),
329
+ JSON.stringify(msg.doubles ?? []),
330
+ );
331
+ return { ok: true };
332
+ }
333
+ case "ae.query": {
334
+ // Sproutboat extension: Cloudflare AE is write-only from a Worker (you
335
+ // query it via the SQL API). Exposed here so a dashboard can read back.
336
+ const ds = requireAe(msg.dataset);
337
+ const limit = Math.min(Math.max(Number(msg.limit) || 20, 1), 200);
338
+ const rows = db.query<{ ts: number; indexes_json: string; blobs_json: string; doubles_json: string }, [string]>(
339
+ "SELECT ts, indexes_json, blobs_json, doubles_json FROM ae WHERE dataset = ? ORDER BY ts DESC, rowid DESC LIMIT " + limit,
340
+ ).all(ds);
341
+ const total = db.query<{ n: number }, [string]>("SELECT count(*) AS n FROM ae WHERE dataset = ?").get(ds);
342
+ return {
343
+ ok: true,
344
+ count: total?.n ?? 0,
345
+ rows: rows.map((r) => ({
346
+ timestamp: r.ts,
347
+ indexes: JSON.parse(r.indexes_json) as unknown,
348
+ blobs: JSON.parse(r.blobs_json) as unknown,
349
+ doubles: JSON.parse(r.doubles_json) as unknown,
350
+ })),
351
+ };
352
+ }
353
+
354
+ case "do.storage.get": {
355
+ const cls = requireDoClass(msg.cls);
356
+ const row = db.query<{ value: string }, [string, string, string]>(
357
+ "SELECT value FROM do_storage WHERE cls = ? AND id = ? AND key = ?",
358
+ ).get(cls, str(msg.id), str(msg.key));
359
+ return row ? { ok: true, found: true, value: row.value } : { ok: true, found: false };
360
+ }
361
+ case "do.storage.put":
362
+ db.query(
363
+ "INSERT INTO do_storage (cls, id, key, value) VALUES (?1,?2,?3,?4) " +
364
+ "ON CONFLICT (cls, id, key) DO UPDATE SET value = ?4",
365
+ ).run(requireDoClass(msg.cls), str(msg.id), str(msg.key), str(msg.value));
366
+ return { ok: true };
367
+ case "do.storage.delete": {
368
+ const r = db.query("DELETE FROM do_storage WHERE cls = ? AND id = ? AND key = ?").run(
369
+ requireDoClass(msg.cls), str(msg.id), str(msg.key),
370
+ );
371
+ return { ok: true, deleted: r.changes > 0 };
372
+ }
373
+ case "do.storage.delete_all":
374
+ db.query("DELETE FROM do_storage WHERE cls = ? AND id = ?").run(requireDoClass(msg.cls), str(msg.id));
375
+ return { ok: true };
376
+ case "do.storage.list": {
377
+ const cls = requireDoClass(msg.cls);
378
+ const limit = Math.min(Math.max(Number(msg.limit) || 1000, 1), 10000);
379
+ const rows = db.query<{ key: string; value: string }, [string, string, string]>(
380
+ "SELECT key, value FROM do_storage WHERE cls = ? AND id = ? AND key LIKE ? || '%' ORDER BY key LIMIT " + limit,
381
+ ).all(cls, str(msg.id), str(msg.prefix));
382
+ return { ok: true, entries: rows.map((r) => [r.key, r.value]) };
383
+ }
384
+
385
+ case "assets.get": {
386
+ if (!bindings.assets) throw new Error("assets not bound");
387
+ let path = str(msg.path) || "/";
388
+ if (!path.startsWith("/")) path = `/${path}`;
389
+ if (path.endsWith("/")) path += "index.html";
390
+ const hit = readAsset(path);
391
+ if (hit) return { ok: true, found: true, status: 200, type: hit.type, hash: hit.hash, body: hit.body };
392
+ const nfh = assetManifest?.notFound ?? "none";
393
+ if (nfh === "single-page-application") {
394
+ const shell = readAsset("/index.html");
395
+ if (shell) return { ok: true, found: true, status: 200, type: shell.type, hash: shell.hash, body: shell.body };
396
+ }
397
+ if (nfh === "404-page") {
398
+ const page = readAsset("/404.html");
399
+ if (page) return { ok: true, found: false, status: 404, type: page.type, body: page.body };
400
+ }
401
+ return { ok: true, found: false, status: 404, body: "Not Found" };
402
+ }
403
+
404
+ default:
405
+ throw new Error(`unknown op: ${str(msg.op)}`);
406
+ }
407
+ }
408
+
409
+ async function handlePayload(payload: string): Promise<Frame> {
410
+ const nl = payload.indexOf("\n");
411
+ const gotToken = nl === -1 ? "" : payload.slice(0, nl);
412
+ const json = nl === -1 ? payload : payload.slice(nl + 1);
413
+ if (token && gotToken !== token) return { ok: false, error: "unauthorized" };
414
+ try {
415
+ return await dispatch(JSON.parse(json) as Frame);
416
+ } catch (e) {
417
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
418
+ }
419
+ }
420
+
421
+ // --- cron + queue delivery (only when this broker knows its worker) --------
422
+ const timers: ReturnType<typeof setInterval>[] = [];
423
+ const QUEUE_BATCH = 10;
424
+ const QUEUE_MAX_ATTEMPTS = 5;
425
+
426
+ async function deliverTrigger(kind: "scheduled" | "queue", body: unknown): Promise<Response | null> {
427
+ if (!opts.workerUrl) return null;
428
+ try {
429
+ return await doFetch(opts.workerUrl, {
430
+ method: "POST",
431
+ headers: { "x-sb-trigger": kind, "x-sb-token": token, "content-type": "application/json" },
432
+ body: JSON.stringify(body),
433
+ });
434
+ } catch {
435
+ return null;
436
+ }
437
+ }
438
+
439
+ function drainQueuesOnce(): void {
440
+ if (!opts.workerUrl || bindings.queues.length === 0) return;
441
+ const now = Date.now();
442
+ for (const q of bindings.queues) {
443
+ const rows = db.query<{ id: string; body: string; attempts: number }, [string, number, number]>(
444
+ "SELECT id, body, attempts FROM mq WHERE queue = ? AND dead = 0 AND visible_at <= ? ORDER BY visible_at LIMIT ?",
445
+ ).all(q, now, QUEUE_BATCH);
446
+ if (rows.length === 0) continue;
447
+ // hide the batch so the next tick doesn't re-deliver it while in flight
448
+ const hideUntil = now + 30_000;
449
+ const hide = db.query("UPDATE mq SET visible_at = ? WHERE id = ?");
450
+ for (const r of rows) hide.run(hideUntil, r.id);
451
+
452
+ void deliverTrigger("queue", {
453
+ queue: q,
454
+ messages: rows.map((r) => ({ id: r.id, body: r.body, timestamp: now, attempts: r.attempts + 1 })),
455
+ }).then(async (res) => {
456
+ let ack: string[] = rows.map((r) => r.id); // default: ack all if the worker didn't say
457
+ let retry: string[] = [];
458
+ if (res && res.ok) {
459
+ try {
460
+ const parsed = (await res.json()) as { ack?: string[]; retry?: string[] };
461
+ ack = Array.isArray(parsed.ack) ? parsed.ack : ack;
462
+ retry = Array.isArray(parsed.retry) ? parsed.retry : [];
463
+ } catch { /* keep defaults */ }
464
+ } else {
465
+ ack = []; retry = rows.map((r) => r.id); // delivery failed → retry all
466
+ }
467
+ const del = db.query("DELETE FROM mq WHERE id = ?");
468
+ for (const id of ack) del.run(id);
469
+ const bump = db.query(
470
+ "UPDATE mq SET attempts = attempts + 1, visible_at = ?, dead = CASE WHEN attempts + 1 >= ? THEN 1 ELSE 0 END WHERE id = ?",
471
+ );
472
+ for (const id of retry) bump.run(Date.now() + 5_000, QUEUE_MAX_ATTEMPTS, id);
473
+ });
474
+ }
475
+ }
476
+
477
+ if (opts.workerUrl) {
478
+ if (bindings.queues.length > 0) timers.push(setInterval(drainQueuesOnce, 500));
479
+ if (bindings.crons.length > 0) {
480
+ let lastTick = "";
481
+ timers.push(setInterval(() => {
482
+ const now = new Date();
483
+ const stamp = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}-${now.getUTCHours()}-${now.getUTCMinutes()}`;
484
+ if (stamp === lastTick) return; // once per minute
485
+ lastTick = stamp;
486
+ for (const expr of bindings.crons) {
487
+ if (cronMatches(expr, now)) void deliverTrigger("scheduled", { cron: expr, scheduledTime: now.getTime() });
488
+ }
489
+ }, 15_000));
490
+ }
491
+ }
492
+
493
+ return {
494
+ dispatch,
495
+ handlePayload,
496
+ close: () => {
497
+ for (const t of timers) clearInterval(t);
498
+ for (const conn of d1Conns.values()) conn.close();
499
+ db.close();
500
+ },
501
+ };
502
+ }
503
+
504
+ /**
505
+ * Minimal 5-field cron match (`min hour dom month dow`, UTC). Each field is a
506
+ * comma list of: `*`, a step `*` + `/n`, a range `a-b`, or a plain number.
507
+ */
508
+ export function cronMatches(expr: string, when: Date): boolean {
509
+ const parts = expr.trim().split(/\s+/);
510
+ if (parts.length !== 5) return false;
511
+ const fields = [when.getUTCMinutes(), when.getUTCHours(), when.getUTCDate(), when.getUTCMonth() + 1, when.getUTCDay()];
512
+ const inField = (spec: string, value: number): boolean =>
513
+ spec.split(",").some((token) => {
514
+ if (token === "*") return true;
515
+ const step = token.startsWith("*/") ? Number(token.slice(2)) : null;
516
+ if (step) return value % step === 0;
517
+ const range = token.split("-");
518
+ if (range.length === 2) return value >= Number(range[0]) && value <= Number(range[1]);
519
+ return Number(token) === value;
520
+ });
521
+ return parts.every((spec, i) => inField(spec, fields[i]));
522
+ }
523
+
524
+ export function encodeFrame(obj: Frame): Buffer {
525
+ const body = Buffer.from(JSON.stringify(obj), "utf8");
526
+ const frame = Buffer.allocUnsafe(4 + body.length);
527
+ frame.writeUInt32LE(body.length, 0);
528
+ body.copy(frame, 4);
529
+ return frame;
530
+ }
531
+
532
+ /** Start the TCP listener. Returns the bound port. */
533
+ export function listen(broker: Broker, hostname: string, port: number): { port: number; stop(): void } {
534
+ const server = Bun.listen<{ buf: Buffer }>({
535
+ hostname,
536
+ port,
537
+ socket: {
538
+ open(socket) {
539
+ socket.data = { buf: Buffer.alloc(0) };
540
+ },
541
+ async data(socket, chunk) {
542
+ const state = socket.data;
543
+ state.buf = state.buf.length ? Buffer.concat([state.buf, chunk]) : chunk;
544
+ for (;;) {
545
+ if (state.buf.length < 4) return;
546
+ const len = state.buf.readUInt32LE(0);
547
+ if (len > MAX_FRAME) {
548
+ socket.write(encodeFrame({ ok: false, error: "frame too large" }));
549
+ socket.end();
550
+ return;
551
+ }
552
+ if (state.buf.length < 4 + len) return;
553
+ const payload = Buffer.from(state.buf.subarray(4, 4 + len)).toString("utf8");
554
+ state.buf = Buffer.from(state.buf.subarray(4 + len));
555
+ socket.write(encodeFrame(await broker.handlePayload(payload)));
556
+ }
557
+ },
558
+ },
559
+ });
560
+ return { port: server.port, stop: () => server.stop(true) };
561
+ }
562
+
563
+ if (import.meta.main) {
564
+ const { values } = parseArgs({
565
+ options: {
566
+ port: { type: "string" },
567
+ token: { type: "string" },
568
+ db: { type: "string" },
569
+ "data-dir": { type: "string" },
570
+ bindings: { type: "string" },
571
+ secrets: { type: "string" },
572
+ "worker-url": { type: "string" },
573
+ "assets-dir": { type: "string" },
574
+ },
575
+ });
576
+ const bindings: Partial<Bindings> | undefined = values.bindings
577
+ ? (JSON.parse(readFileSync(values.bindings, "utf8")) as Partial<Bindings>)
578
+ : undefined;
579
+ const secrets: Record<string, string> | undefined = values.secrets
580
+ ? (JSON.parse(readFileSync(values.secrets, "utf8")) as Record<string, string>)
581
+ : undefined;
582
+ const broker = createBroker({
583
+ db: values.db,
584
+ dataDir: values["data-dir"],
585
+ token: values.token ?? process.env.SB_BROKER_TOKEN,
586
+ bindings,
587
+ secrets,
588
+ workerUrl: values["worker-url"] ?? process.env.SB_WORKER_URL,
589
+ assetsDir: values["assets-dir"],
590
+ });
591
+ const { port } = listen(broker, "127.0.0.1", Number(values.port ?? process.env.SB_BROKER_PORT ?? 0));
592
+ console.log(`sproutboat broker: 127.0.0.1:${port} db=${values.db ?? ":memory:"} worker=${values["worker-url"] ?? process.env.SB_WORKER_URL ?? "(none)"}`);
593
+ }