sproutboat 0.4.11 → 0.6.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 { isBoolean, isString, jsonObject, parseJsonValue, type JsonObject, type JsonValue } from "./json";
26
27
 
27
28
  export type Bindings = {
28
29
  kv: string[];
@@ -44,7 +45,12 @@ export type Bindings = {
44
45
  */
45
46
  resources: Record<string, { kind: "kv" | "d1" | "r2" | "queue"; id: string }>;
46
47
  };
47
- export type Frame = Record<string, unknown>;
48
+ /** One decoded wire frame. `undefined` fields are dropped by `JSON.stringify`, so
49
+ * an optional reply field can be left off without a second object shape. */
50
+ export type Frame = { [key: string]: JsonValue | undefined };
51
+
52
+ /** The only shape of `fetch` the broker calls — the global `fetch` satisfies it. */
53
+ export type FetchLike = (input: URL | string, init?: RequestInit) => Promise<Response>;
48
54
 
49
55
  export type BrokerOptions = {
50
56
  db?: string;
@@ -69,21 +75,35 @@ export type BrokerOptions = {
69
75
  /** Directory of published static assets (its sibling `assets.json` is the manifest). Backs `assets.get`. */
70
76
  assetsDir?: string;
71
77
  /** Injected in tests; defaults to the global `fetch`. */
72
- fetchImpl?: typeof fetch;
78
+ fetchImpl?: FetchLike;
73
79
  };
74
80
 
75
81
  type SqlParam = string | number | null;
76
- type R2Row = { key: string; body: string; size: number; etag: string; uploaded: string; http_json: string; custom_json: string };
77
- const sqlParams = (v: unknown): SqlParam[] => {
82
+ /** The `{ results, meta }` shape Cloudflare's D1 client expects back per statement. */
83
+ type D1Result = {
84
+ results: JsonValue[];
85
+ meta: { duration: number; changes: number; last_row_id: number; rows_read: number };
86
+ };
87
+ type R2Row = {
88
+ key: string;
89
+ body: string;
90
+ size: number;
91
+ etag: string;
92
+ uploaded: string;
93
+ http_json: string;
94
+ custom_json: string;
95
+ };
96
+ const isNumber = (v: JsonValue | undefined): v is number => Number.isFinite(v);
97
+ const sqlParams = (v: JsonValue | undefined): SqlParam[] => {
78
98
  if (!Array.isArray(v)) return [];
79
- return v.map((p) => (p === null || typeof p === "number" || typeof p === "string" ? p : typeof p === "boolean" ? (p ? 1 : 0) : String(p)));
99
+ return v.map((p) => (p === null || isNumber(p) || isString(p) ? p : isBoolean(p) ? (p ? 1 : 0) : String(p)));
80
100
  };
81
101
 
82
102
  // One binding call = one frame, and an R2/KV value travels inside it as a JSON
83
103
  // string (escaping can inflate binary content several ×). 32 MiB keeps a ~25 MB
84
104
  // upload working; true large-object streaming is v2.
85
105
  const MAX_FRAME = 32 * 1024 * 1024;
86
- const str = (v: unknown): string => (typeof v === "string" ? v : String(v ?? ""));
106
+ const str = (v: JsonValue | undefined): string => (isString(v) ? v : String(v ?? ""));
87
107
 
88
108
  export type Broker = {
89
109
  /** Run one parsed request object through the op dispatch. */
@@ -94,7 +114,20 @@ export type Broker = {
94
114
  };
95
115
 
96
116
  export function createBroker(opts: BrokerOptions = {}): Broker {
97
- const bindings: Bindings = { kv: [], secrets: [], outbound: [], d1: [], r2: [], queues: [], analytics: [], do: [], crons: [], assets: "", resources: {}, ...opts.bindings };
117
+ const bindings: Bindings = {
118
+ kv: [],
119
+ secrets: [],
120
+ outbound: [],
121
+ d1: [],
122
+ r2: [],
123
+ queues: [],
124
+ analytics: [],
125
+ do: [],
126
+ crons: [],
127
+ assets: "",
128
+ resources: {},
129
+ ...opts.bindings,
130
+ };
98
131
  const secrets = opts.secrets ?? {};
99
132
 
100
133
  // Static assets: read the manifest once. Files are read from disk per request
@@ -103,7 +136,11 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
103
136
  let assetManifest: AssetManifest | null = null;
104
137
  if (assetsDir) {
105
138
  const manifestPath = join(dirname(assetsDir), "assets.json");
106
- if (existsSync(manifestPath)) assetManifest = JSON.parse(readFileSync(manifestPath, "utf8")) as AssetManifest;
139
+ if (existsSync(manifestPath)) {
140
+ // SAFETY: assets.json sits beside the published assets dir and is written
141
+ // only by `sproutboat build` from the AssetManifest contract.
142
+ assetManifest = JSON.parse(readFileSync(manifestPath, "utf8")) as AssetManifest;
143
+ }
107
144
  }
108
145
  const readAsset = (path: string): { type: string; hash: string; body: string } | null => {
109
146
  if (!assetsDir || !assetManifest) return null;
@@ -130,7 +167,9 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
130
167
  const conn = new Database(path, { create: true });
131
168
  conn.exec("PRAGMA journal_mode = WAL");
132
169
  conn.exec("PRAGMA synchronous = NORMAL");
133
- conn.exec("CREATE TABLE IF NOT EXISTS kv (ns TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (ns, key))");
170
+ conn.exec(
171
+ "CREATE TABLE IF NOT EXISTS kv (ns TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (ns, key))",
172
+ );
134
173
  conn.exec(
135
174
  "CREATE TABLE IF NOT EXISTS r2 (bucket TEXT NOT NULL, key TEXT NOT NULL, body TEXT NOT NULL, size INTEGER NOT NULL, " +
136
175
  "etag TEXT NOT NULL, uploaded TEXT NOT NULL, http_json TEXT NOT NULL DEFAULT '{}', custom_json TEXT NOT NULL DEFAULT '{}', " +
@@ -191,27 +230,33 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
191
230
  if (!stmts) {
192
231
  stmts = {
193
232
  get: store.query<{ value: string }, [string, string]>("SELECT value FROM kv WHERE ns = ? AND key = ?"),
194
- put: store.query("INSERT INTO kv (ns, key, value) VALUES (?1, ?2, ?3) ON CONFLICT (ns, key) DO UPDATE SET value = ?3"),
233
+ put: store.query(
234
+ "INSERT INTO kv (ns, key, value) VALUES (?1, ?2, ?3) ON CONFLICT (ns, key) DO UPDATE SET value = ?3",
235
+ ),
195
236
  del: store.query("DELETE FROM kv WHERE ns = ? AND key = ?"),
196
- list: store.query<{ key: string }, [string, string]>("SELECT key FROM kv WHERE ns = ? AND key LIKE ? || '%' ORDER BY key LIMIT 1000"),
237
+ list: store.query<{ key: string }, [string, string]>(
238
+ "SELECT key FROM kv WHERE ns = ? AND key LIKE ? || '%' ORDER BY key LIMIT 1000",
239
+ ),
197
240
  };
198
241
  kvStmtCache.set(store, stmts);
199
242
  }
200
243
  return stmts;
201
244
  };
202
245
 
203
- const bound = (list: string[], kind: string) => (name: unknown): string => {
204
- const n = str(name);
205
- if (!list.includes(n)) throw new Error(`${kind} not bound: ${n}`);
206
- return n;
207
- };
246
+ const bound =
247
+ (list: string[], kind: string) =>
248
+ (name: JsonValue | undefined): string => {
249
+ const n = str(name);
250
+ if (!list.includes(n)) throw new Error(`${kind} not bound: ${n}`);
251
+ return n;
252
+ };
208
253
  const requireKv = bound(bindings.kv, "KV namespace");
209
254
  const requireD1 = bound(bindings.d1, "D1 database");
210
255
  const requireR2 = bound(bindings.r2, "R2 bucket");
211
256
  const requireQueue = bound(bindings.queues, "queue");
212
257
  const requireAe = bound(bindings.analytics, "analytics dataset");
213
258
  const doClasses = new Set(bindings.do.map((d) => d.className));
214
- const requireDoClass = (cls: unknown): string => {
259
+ const requireDoClass = (cls: JsonValue | undefined): string => {
215
260
  const n = str(cls);
216
261
  if (!doClasses.has(n)) throw new Error(`Durable Object class not bound: ${n}`);
217
262
  return n;
@@ -239,15 +284,23 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
239
284
  };
240
285
 
241
286
  // Run one statement, return CF-D1-shaped { results, meta }.
242
- const d1Run = (conn: Database, sql: string, params: SqlParam[]): { results: unknown[]; meta: Record<string, unknown> } => {
287
+ const d1Run = (conn: Database, sql: string, params: SqlParam[]): D1Result => {
243
288
  const started = performance.now();
244
- const results = conn.query(sql).all(...params);
245
- const m = conn.query<{ changes: number; last_row_id: number }, []>(
246
- "SELECT changes() AS changes, last_insert_rowid() AS last_row_id",
247
- ).get();
289
+ // SQLite hands back column->(string|number|null|blob) rows, i.e. a JSON object.
290
+ const results = conn.query<JsonObject, SqlParam[]>(sql).all(...params);
291
+ const m = conn
292
+ .query<{ changes: number; last_row_id: number }, []>(
293
+ "SELECT changes() AS changes, last_insert_rowid() AS last_row_id",
294
+ )
295
+ .get();
248
296
  return {
249
297
  results,
250
- meta: { duration: performance.now() - started, changes: m?.changes ?? 0, last_row_id: m?.last_row_id ?? 0, rows_read: results.length },
298
+ meta: {
299
+ duration: performance.now() - started,
300
+ changes: m?.changes ?? 0,
301
+ last_row_id: m?.last_row_id ?? 0,
302
+ rows_read: results.length,
303
+ },
251
304
  };
252
305
  };
253
306
 
@@ -256,8 +309,8 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
256
309
  size: r.size,
257
310
  etag: r.etag,
258
311
  uploaded: r.uploaded,
259
- httpMetadata: JSON.parse(r.http_json) as unknown,
260
- customMetadata: JSON.parse(r.custom_json) as unknown,
312
+ httpMetadata: parseJsonValue(r.http_json),
313
+ customMetadata: parseJsonValue(r.custom_json),
261
314
  });
262
315
 
263
316
  async function proxyFetch(msg: Frame): Promise<Frame> {
@@ -272,7 +325,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
272
325
 
273
326
  const headers = new Headers();
274
327
  if (Array.isArray(msg.headers)) {
275
- for (const pair of msg.headers as unknown[]) {
328
+ for (const pair of msg.headers) {
276
329
  if (Array.isArray(pair) && pair.length === 2) headers.set(str(pair[0]), str(pair[1]));
277
330
  }
278
331
  }
@@ -309,7 +362,12 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
309
362
  }
310
363
  case "kv.list": {
311
364
  const { store, part } = storeFor("kv", requireKv(msg.ns));
312
- return { ok: true, keys: kvStmts(store).list.all(part, str(msg.prefix)).map((r) => r.key) };
365
+ return {
366
+ ok: true,
367
+ keys: kvStmts(store)
368
+ .list.all(part, str(msg.prefix))
369
+ .map((r) => r.key),
370
+ };
313
371
  }
314
372
  case "secret.get": {
315
373
  const name = str(msg.name);
@@ -326,7 +384,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
326
384
  }
327
385
  case "d1.batch": {
328
386
  const conn = d1(requireD1(msg.db));
329
- const stmts = Array.isArray(msg.statements) ? (msg.statements as Frame[]) : [];
387
+ const stmts = Array.isArray(msg.statements) ? msg.statements.map((s) => jsonObject(s) ?? {}) : [];
330
388
  const runAll = conn.transaction(() => stmts.map((s) => d1Run(conn, str(s.sql), sqlParams(s.params))));
331
389
  return { ok: true, results: runAll() };
332
390
  }
@@ -340,25 +398,32 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
340
398
  const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
341
399
  const body = str(msg.body);
342
400
  const etag = createHash("sha256").update(body).digest("hex");
343
- store.query(
344
- "INSERT INTO r2 (bucket, key, body, size, etag, uploaded, http_json, custom_json) VALUES (?1,?2,?3,?4,?5,?6,?7,?8) " +
345
- "ON CONFLICT (bucket, key) DO UPDATE SET body=?3, size=?4, etag=?5, uploaded=?6, http_json=?7, custom_json=?8",
346
- ).run(
347
- bucket,
348
- str(msg.key),
349
- body,
350
- Buffer.byteLength(body),
351
- etag,
352
- new Date().toISOString(),
353
- JSON.stringify(msg.httpMetadata ?? {}),
354
- JSON.stringify(msg.customMetadata ?? {}),
355
- );
356
- return { ok: true, object: { key: str(msg.key), size: Buffer.byteLength(body), etag, uploaded: new Date().toISOString() } };
401
+ store
402
+ .query(
403
+ "INSERT INTO r2 (bucket, key, body, size, etag, uploaded, http_json, custom_json) VALUES (?1,?2,?3,?4,?5,?6,?7,?8) " +
404
+ "ON CONFLICT (bucket, key) DO UPDATE SET body=?3, size=?4, etag=?5, uploaded=?6, http_json=?7, custom_json=?8",
405
+ )
406
+ .run(
407
+ bucket,
408
+ str(msg.key),
409
+ body,
410
+ Buffer.byteLength(body),
411
+ etag,
412
+ new Date().toISOString(),
413
+ JSON.stringify(msg.httpMetadata ?? {}),
414
+ JSON.stringify(msg.customMetadata ?? {}),
415
+ );
416
+ return {
417
+ ok: true,
418
+ object: { key: str(msg.key), size: Buffer.byteLength(body), etag, uploaded: new Date().toISOString() },
419
+ };
357
420
  }
358
421
  case "r2.get":
359
422
  case "r2.head": {
360
423
  const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
361
- const row = store.query<R2Row, [string, string]>("SELECT * FROM r2 WHERE bucket = ? AND key = ?").get(bucket, str(msg.key));
424
+ const row = store
425
+ .query<R2Row, [string, string]>("SELECT * FROM r2 WHERE bucket = ? AND key = ?")
426
+ .get(bucket, str(msg.key));
362
427
  if (!row) return { ok: true, found: false };
363
428
  return { ok: true, found: true, object: r2Row(row), body: msg.op === "r2.get" ? row.body : undefined };
364
429
  }
@@ -372,9 +437,11 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
372
437
  const prefix = str(msg.prefix);
373
438
  const cursor = str(msg.cursor);
374
439
  const limit = Math.min(Math.max(Number(msg.limit) || 1000, 1), 1000);
375
- const rows = store.query<R2Row, [string, string, string, number]>(
376
- "SELECT * FROM r2 WHERE bucket = ? AND key LIKE ? || '%' AND key > ? ORDER BY key LIMIT ?",
377
- ).all(bucket, prefix, cursor, limit + 1);
440
+ const rows = store
441
+ .query<R2Row, [string, string, string, number]>(
442
+ "SELECT * FROM r2 WHERE bucket = ? AND key LIKE ? || '%' AND key > ? ORDER BY key LIMIT ?",
443
+ )
444
+ .all(bucket, prefix, cursor, limit + 1);
378
445
  const truncated = rows.length > limit;
379
446
  const page = truncated ? rows.slice(0, limit) : rows;
380
447
  return {
@@ -388,15 +455,18 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
388
455
  case "queue.send": {
389
456
  const { store, part: q } = storeFor("queue", requireQueue(msg.queue));
390
457
  const at = Date.now() + Math.max(0, Number(msg.delaySeconds) || 0) * 1000;
391
- store.query("INSERT INTO mq (queue, id, body, visible_at) VALUES (?, ?, ?, ?)").run(q, newId(), str(msg.body), at);
458
+ store
459
+ .query("INSERT INTO mq (queue, id, body, visible_at) VALUES (?, ?, ?, ?)")
460
+ .run(q, newId(), str(msg.body), at);
392
461
  return { ok: true };
393
462
  }
394
463
  case "queue.send_batch": {
395
464
  const { store, part: q } = storeFor("queue", requireQueue(msg.queue));
396
- const msgs = Array.isArray(msg.messages) ? (msg.messages as Frame[]) : [];
465
+ const msgs = Array.isArray(msg.messages) ? msg.messages.map((m) => jsonObject(m) ?? {}) : [];
397
466
  const ins = store.query("INSERT INTO mq (queue, id, body, visible_at) VALUES (?, ?, ?, ?)");
398
467
  store.transaction(() => {
399
- for (const m of msgs) ins.run(q, newId(), str(m.body), Date.now() + Math.max(0, Number(m.delaySeconds) || 0) * 1000);
468
+ for (const m of msgs)
469
+ ins.run(q, newId(), str(m.body), Date.now() + Math.max(0, Number(m.delaySeconds) || 0) * 1000);
400
470
  })();
401
471
  return { ok: true, count: msgs.length };
402
472
  }
@@ -417,27 +487,31 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
417
487
  // query it via the SQL API). Exposed here so a dashboard can read back.
418
488
  const ds = requireAe(msg.dataset);
419
489
  const limit = Math.min(Math.max(Number(msg.limit) || 20, 1), 200);
420
- const rows = db.query<{ ts: number; indexes_json: string; blobs_json: string; doubles_json: string }, [string, number]>(
421
- "SELECT ts, indexes_json, blobs_json, doubles_json FROM ae WHERE dataset = ? ORDER BY ts DESC, rowid DESC LIMIT ?",
422
- ).all(ds, limit);
490
+ const rows = db
491
+ .query<{ ts: number; indexes_json: string; blobs_json: string; doubles_json: string }, [string, number]>(
492
+ "SELECT ts, indexes_json, blobs_json, doubles_json FROM ae WHERE dataset = ? ORDER BY ts DESC, rowid DESC LIMIT ?",
493
+ )
494
+ .all(ds, limit);
423
495
  const total = db.query<{ n: number }, [string]>("SELECT count(*) AS n FROM ae WHERE dataset = ?").get(ds);
424
496
  return {
425
497
  ok: true,
426
498
  count: total?.n ?? 0,
427
499
  rows: rows.map((r) => ({
428
500
  timestamp: r.ts,
429
- indexes: JSON.parse(r.indexes_json) as unknown,
430
- blobs: JSON.parse(r.blobs_json) as unknown,
431
- doubles: JSON.parse(r.doubles_json) as unknown,
501
+ indexes: parseJsonValue(r.indexes_json),
502
+ blobs: parseJsonValue(r.blobs_json),
503
+ doubles: parseJsonValue(r.doubles_json),
432
504
  })),
433
505
  };
434
506
  }
435
507
 
436
508
  case "do.storage.get": {
437
509
  const cls = requireDoClass(msg.cls);
438
- const row = db.query<{ value: string }, [string, string, string]>(
439
- "SELECT value FROM do_storage WHERE cls = ? AND id = ? AND key = ?",
440
- ).get(cls, str(msg.id), str(msg.key));
510
+ const row = db
511
+ .query<{ value: string }, [string, string, string]>(
512
+ "SELECT value FROM do_storage WHERE cls = ? AND id = ? AND key = ?",
513
+ )
514
+ .get(cls, str(msg.id), str(msg.key));
441
515
  return row ? { ok: true, found: true, value: row.value } : { ok: true, found: false };
442
516
  }
443
517
  case "do.storage.put":
@@ -447,9 +521,9 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
447
521
  ).run(requireDoClass(msg.cls), str(msg.id), str(msg.key), str(msg.value));
448
522
  return { ok: true };
449
523
  case "do.storage.delete": {
450
- const r = db.query("DELETE FROM do_storage WHERE cls = ? AND id = ? AND key = ?").run(
451
- requireDoClass(msg.cls), str(msg.id), str(msg.key),
452
- );
524
+ const r = db
525
+ .query("DELETE FROM do_storage WHERE cls = ? AND id = ? AND key = ?")
526
+ .run(requireDoClass(msg.cls), str(msg.id), str(msg.key));
453
527
  return { ok: true, deleted: r.changes > 0 };
454
528
  }
455
529
  case "do.storage.delete_all":
@@ -458,9 +532,11 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
458
532
  case "do.storage.list": {
459
533
  const cls = requireDoClass(msg.cls);
460
534
  const limit = Math.min(Math.max(Number(msg.limit) || 1000, 1), 10000);
461
- const rows = db.query<{ key: string; value: string }, [string, string, string, number]>(
462
- "SELECT key, value FROM do_storage WHERE cls = ? AND id = ? AND key LIKE ? || '%' ORDER BY key LIMIT ?",
463
- ).all(cls, str(msg.id), str(msg.prefix), limit);
535
+ const rows = db
536
+ .query<{ key: string; value: string }, [string, string, string, number]>(
537
+ "SELECT key, value FROM do_storage WHERE cls = ? AND id = ? AND key LIKE ? || '%' ORDER BY key LIMIT ?",
538
+ )
539
+ .all(cls, str(msg.id), str(msg.prefix), limit);
464
540
  return { ok: true, entries: rows.map((r) => [r.key, r.value]) };
465
541
  }
466
542
 
@@ -473,7 +549,8 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
473
549
  const nfh = assetManifest?.notFound ?? "none";
474
550
  if (nfh === "single-page-application") {
475
551
  const shell = readAsset("/index.html");
476
- if (shell) return { ok: true, found: true, status: 200, type: shell.type, hash: shell.hash, body: shell.body };
552
+ if (shell)
553
+ return { ok: true, found: true, status: 200, type: shell.type, hash: shell.hash, body: shell.body };
477
554
  }
478
555
  if (nfh === "404-page") {
479
556
  const page = readAsset("/404.html");
@@ -493,7 +570,9 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
493
570
  const json = nl === -1 ? payload : payload.slice(nl + 1);
494
571
  if (token && gotToken !== token) return { ok: false, error: "unauthorized" };
495
572
  try {
496
- return await dispatch(JSON.parse(json) as Frame);
573
+ const msg = jsonObject(parseJsonValue(json));
574
+ if (!msg) throw new Error("request frame was not a JSON object");
575
+ return await dispatch(msg);
497
576
  } catch (e) {
498
577
  return { ok: false, error: e instanceof Error ? e.message : String(e) };
499
578
  }
@@ -504,7 +583,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
504
583
  const QUEUE_BATCH = 10;
505
584
  const QUEUE_MAX_ATTEMPTS = 5;
506
585
 
507
- async function deliverTrigger(kind: "scheduled" | "queue", body: unknown): Promise<Response | null> {
586
+ async function deliverTrigger(kind: "scheduled" | "queue", body: JsonObject): Promise<Response | null> {
508
587
  if (!opts.sproutUrl) return null;
509
588
  try {
510
589
  return await doFetch(opts.sproutUrl, {
@@ -524,9 +603,11 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
524
603
  // `store`/`part` route the rows to this queue's backing file (#74); the
525
604
  // trigger payload still carries the binding name the sprout matches on.
526
605
  const { store, part } = storeFor("queue", binding);
527
- const rows = store.query<{ id: string; body: string; attempts: number }, [string, number, number]>(
528
- "SELECT id, body, attempts FROM mq WHERE queue = ? AND dead = 0 AND visible_at <= ? ORDER BY visible_at LIMIT ?",
529
- ).all(part, now, QUEUE_BATCH);
606
+ const rows = store
607
+ .query<{ id: string; body: string; attempts: number }, [string, number, number]>(
608
+ "SELECT id, body, attempts FROM mq WHERE queue = ? AND dead = 0 AND visible_at <= ? ORDER BY visible_at LIMIT ?",
609
+ )
610
+ .all(part, now, QUEUE_BATCH);
530
611
  if (rows.length === 0) continue;
531
612
  // hide the batch so the next tick doesn't re-deliver it while in flight
532
613
  const hideUntil = now + 30_000;
@@ -541,12 +622,16 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
541
622
  let retry: string[] = [];
542
623
  if (res && res.ok) {
543
624
  try {
544
- const parsed = (await res.json()) as { ack?: string[]; retry?: string[] };
545
- ack = Array.isArray(parsed.ack) ? parsed.ack : ack;
546
- retry = Array.isArray(parsed.retry) ? parsed.retry : [];
547
- } catch { /* keep defaults */ }
625
+ const parsed = jsonObject(parseJsonValue(await res.text()));
626
+ const ids = (value: JsonValue | undefined) => (Array.isArray(value) ? value.filter(isString) : null);
627
+ ack = (parsed && ids(parsed.ack)) ?? ack;
628
+ retry = (parsed && ids(parsed.retry)) ?? [];
629
+ } catch {
630
+ /* keep defaults */
631
+ }
548
632
  } else {
549
- ack = []; retry = rows.map((r) => r.id); // delivery failed → retry all
633
+ ack = [];
634
+ retry = rows.map((r) => r.id); // delivery failed → retry all
550
635
  }
551
636
  const del = store.query("DELETE FROM mq WHERE id = ?");
552
637
  for (const id of ack) del.run(id);
@@ -562,15 +647,17 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
562
647
  if (bindings.queues.length > 0) timers.push(setInterval(drainQueuesOnce, 500));
563
648
  if (bindings.crons.length > 0) {
564
649
  let lastTick = "";
565
- timers.push(setInterval(() => {
566
- const now = new Date();
567
- const stamp = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}-${now.getUTCHours()}-${now.getUTCMinutes()}`;
568
- if (stamp === lastTick) return; // once per minute
569
- lastTick = stamp;
570
- for (const expr of bindings.crons) {
571
- if (cronMatches(expr, now)) void deliverTrigger("scheduled", { cron: expr, scheduledTime: now.getTime() });
572
- }
573
- }, 15_000));
650
+ timers.push(
651
+ setInterval(() => {
652
+ const now = new Date();
653
+ const stamp = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}-${now.getUTCHours()}-${now.getUTCMinutes()}`;
654
+ if (stamp === lastTick) return; // once per minute
655
+ lastTick = stamp;
656
+ for (const expr of bindings.crons) {
657
+ if (cronMatches(expr, now)) void deliverTrigger("scheduled", { cron: expr, scheduledTime: now.getTime() });
658
+ }
659
+ }, 15_000),
660
+ );
574
661
  }
575
662
  }
576
663
 
@@ -593,7 +680,13 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
593
680
  export function cronMatches(expr: string, when: Date): boolean {
594
681
  const parts = expr.trim().split(/\s+/);
595
682
  if (parts.length !== 5) return false;
596
- const fields = [when.getUTCMinutes(), when.getUTCHours(), when.getUTCDate(), when.getUTCMonth() + 1, when.getUTCDay()];
683
+ const fields = [
684
+ when.getUTCMinutes(),
685
+ when.getUTCHours(),
686
+ when.getUTCDate(),
687
+ when.getUTCMonth() + 1,
688
+ when.getUTCDay(),
689
+ ];
597
690
  const inField = (spec: string, value: number): boolean =>
598
691
  spec.split(",").some((token) => {
599
692
  if (token === "*") return true;
@@ -614,8 +707,11 @@ export function encodeFrame(obj: Frame): Buffer {
614
707
  return frame;
615
708
  }
616
709
 
710
+ /** The bound TCP listener: the port actually assigned, and its shutdown handle. */
711
+ export type BrokerServer = { port: number; stop(): void };
712
+
617
713
  /** Start the TCP listener. Returns the bound port. */
618
- export function listen(broker: Broker, hostname: string, port: number): { port: number; stop(): void } {
714
+ export function listen(broker: Broker, hostname: string, port: number): BrokerServer {
619
715
  const server = Bun.listen<{ buf: Buffer }>({
620
716
  hostname,
621
717
  port,
@@ -659,9 +755,12 @@ if (import.meta.main) {
659
755
  "assets-dir": { type: "string" },
660
756
  },
661
757
  });
758
+ // SAFETY: --bindings and --secrets are the artifact's own bindings.json /
759
+ // secrets.json, written by `sproutboat build` and handed to us by the supervisor.
662
760
  const bindings: Partial<Bindings> | undefined = values.bindings
663
761
  ? (JSON.parse(readFileSync(values.bindings, "utf8")) as Partial<Bindings>)
664
762
  : undefined;
763
+ // SAFETY: as above — secrets.json is a flat name->value map written by the build.
665
764
  const secrets: Record<string, string> | undefined = values.secrets
666
765
  ? (JSON.parse(readFileSync(values.secrets, "utf8")) as Record<string, string>)
667
766
  : undefined;
@@ -676,5 +775,7 @@ if (import.meta.main) {
676
775
  assetsDir: values["assets-dir"],
677
776
  });
678
777
  const { port } = listen(broker, "127.0.0.1", Number(values.port ?? process.env.SB_BROKER_PORT ?? 0));
679
- console.log(`sproutboat broker: 127.0.0.1:${port} db=${values.db ?? ":memory:"} sprout=${values["sprout-url"] ?? process.env.SB_SPROUT_URL ?? "(none)"}`);
778
+ console.log(
779
+ `sproutboat broker: 127.0.0.1:${port} db=${values.db ?? ":memory:"} sprout=${values["sprout-url"] ?? process.env.SB_SPROUT_URL ?? "(none)"}`,
780
+ );
680
781
  }
package/src/build.ts CHANGED
@@ -4,13 +4,32 @@ import { resolve } from "node:path";
4
4
  import { walkAssets, type AssetManifest } from "./assets";
5
5
  import { resourceRefs, type SproutboatConfig } from "./config";
6
6
  import { compileSprout } from "./compile";
7
- import { ARTIFACT_SCHEMA_VERSION, CAPABILITY_PROFILE, RUNTIME, type ArtifactManifest } from "./manifest";
7
+ import {
8
+ ARTIFACT_SCHEMA_VERSION,
9
+ CAPABILITY_PROFILE,
10
+ DEPLOY_TARGET,
11
+ hostTarget,
12
+ RUNTIME,
13
+ type ArtifactManifest,
14
+ } from "./manifest";
8
15
  import { ensureZig, esbuildVersion, porfforVersion, toolchainStamp } from "./toolchain";
9
16
 
10
17
  export type BuildInput = {
11
18
  projectDir: string;
12
19
  config: SproutboatConfig;
13
20
  sourcePath: string;
21
+ /**
22
+ * The bundled module (#89). When present this is what gets hashed and
23
+ * compiled, so the artifact tracks every imported file rather than just the
24
+ * entry point — change a dependency, get a different version.
25
+ */
26
+ source?: string;
27
+ /**
28
+ * `host` (#62) compiles for this machine instead of cross-compiling for a
29
+ * box, so `sproutboat dev` can run the sprout locally. The manifest records
30
+ * the real target, which is what stops the result being deployed.
31
+ */
32
+ target?: "linux-x86_64" | "host";
14
33
  };
15
34
 
16
35
  export type BuildOutput = {
@@ -29,7 +48,7 @@ function digest(value: Uint8Array | string): `sha256:${string}` {
29
48
  * does not come up.
30
49
  */
31
50
  export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
32
- const source = await readFile(input.sourcePath);
51
+ const source = input.source ?? (await readFile(input.sourcePath));
33
52
  const sourceHash = digest(source);
34
53
  const artifactId = sourceHash.slice("sha256:".length, 24);
35
54
  const artifactDir = resolve(input.projectDir, ".sproutboat/dist", artifactId);
@@ -60,23 +79,33 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
60
79
  do: Object.entries(input.config.durable_objects ?? {}).map(([binding, className]) => ({ binding, className })),
61
80
  crons: input.config.triggers?.crons ?? [],
62
81
  assets: input.config.assets?.binding ?? "",
82
+ // Baked plain values, read as env.NAME. The broker never serves these (they
83
+ // are compiled in via SPROUTBOAT_VARS_JSON); they ride along so the control
84
+ // plane can show what a version was built with. Not secret — `secrets` is
85
+ // that, and it carries names only.
86
+ vars: input.config.vars ?? {},
63
87
  resources,
64
88
  };
65
89
 
66
- const zigBin = await ensureZig();
90
+ // A host build never shells out to `zig`, so do not fetch a 50 MB toolchain
91
+ // for it — that download is the slowest part of a first local build.
92
+ const host = input.target === "host";
93
+ const zigBin = host ? undefined : await ensureZig();
67
94
  await compileSprout({
68
95
  sourcePath: input.sourcePath,
96
+ source: input.source,
69
97
  outPath: sproutPath,
70
98
  vars: input.config.vars ?? {},
71
99
  bindings,
72
100
  zigBin,
101
+ target: input.target,
73
102
  });
74
103
 
75
104
  const sprout = await readFile(sproutPath);
76
105
  const manifest: ArtifactManifest = {
77
106
  schemaVersion: ARTIFACT_SCHEMA_VERSION,
78
107
  project: input.config.name,
79
- target: "linux-x86_64",
108
+ target: host ? hostTarget() : DEPLOY_TARGET,
80
109
  runtime: RUNTIME,
81
110
  capabilityProfile: CAPABILITY_PROFILE,
82
111
  porfforVersion: porfforVersion(),
@@ -92,8 +121,10 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
92
121
  // frozen at v2. The control plane reads this to configure the per-deployment
93
122
  // broker (KV / D1 / R2 / queue names, secret names, outbound allowlist, cron
94
123
  // schedules, Durable Object classes).
95
- const hasBindings = Object.values(bindings).some((value) => Array.isArray(value) && value.length > 0)
96
- || Object.keys(bindings.resources).length > 0;
124
+ const hasBindings =
125
+ Object.values(bindings).some((value) => Array.isArray(value) && value.length > 0) ||
126
+ Object.keys(bindings.resources).length > 0 ||
127
+ Object.keys(bindings.vars).length > 0;
97
128
  if (hasBindings) {
98
129
  await writeFile(resolve(artifactDir, "bindings.json"), `${JSON.stringify(bindings, null, 2)}\n`);
99
130
  }
@@ -104,7 +135,11 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
104
135
  if (input.config.assets) {
105
136
  const srcDir = resolve(input.projectDir, input.config.assets.directory);
106
137
  const outDir = resolve(artifactDir, "assets");
107
- if (!(await stat(srcDir).then((s) => s.isDirectory()).catch(() => false))) {
138
+ if (
139
+ !(await stat(srcDir)
140
+ .then((s) => s.isDirectory())
141
+ .catch(() => false))
142
+ ) {
108
143
  throw new Error(`assets.directory "${input.config.assets.directory}" not found — run your site build first`);
109
144
  }
110
145
  await cp(srcDir, outDir, { recursive: true });