sproutboat 0.5.0 → 0.6.1

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
@@ -80,8 +80,19 @@ export type BrokerOptions = {
80
80
 
81
81
  type SqlParam = string | number | null;
82
82
  /** The `{ results, meta }` shape Cloudflare's D1 client expects back per statement. */
83
- type D1Result = { results: JsonValue[]; meta: { duration: number; changes: number; last_row_id: number; rows_read: number } };
84
- type R2Row = { key: string; body: string; size: number; etag: string; uploaded: string; http_json: string; custom_json: string };
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
+ };
85
96
  const isNumber = (v: JsonValue | undefined): v is number => Number.isFinite(v);
86
97
  const sqlParams = (v: JsonValue | undefined): SqlParam[] => {
87
98
  if (!Array.isArray(v)) return [];
@@ -103,7 +114,20 @@ export type Broker = {
103
114
  };
104
115
 
105
116
  export function createBroker(opts: BrokerOptions = {}): Broker {
106
- 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
+ };
107
131
  const secrets = opts.secrets ?? {};
108
132
 
109
133
  // Static assets: read the manifest once. Files are read from disk per request
@@ -143,7 +167,9 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
143
167
  const conn = new Database(path, { create: true });
144
168
  conn.exec("PRAGMA journal_mode = WAL");
145
169
  conn.exec("PRAGMA synchronous = NORMAL");
146
- 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
+ );
147
173
  conn.exec(
148
174
  "CREATE TABLE IF NOT EXISTS r2 (bucket TEXT NOT NULL, key TEXT NOT NULL, body TEXT NOT NULL, size INTEGER NOT NULL, " +
149
175
  "etag TEXT NOT NULL, uploaded TEXT NOT NULL, http_json TEXT NOT NULL DEFAULT '{}', custom_json TEXT NOT NULL DEFAULT '{}', " +
@@ -204,20 +230,26 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
204
230
  if (!stmts) {
205
231
  stmts = {
206
232
  get: store.query<{ value: string }, [string, string]>("SELECT value FROM kv WHERE ns = ? AND key = ?"),
207
- 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
+ ),
208
236
  del: store.query("DELETE FROM kv WHERE ns = ? AND key = ?"),
209
- 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
+ ),
210
240
  };
211
241
  kvStmtCache.set(store, stmts);
212
242
  }
213
243
  return stmts;
214
244
  };
215
245
 
216
- const bound = (list: string[], kind: string) => (name: JsonValue | undefined): string => {
217
- const n = str(name);
218
- if (!list.includes(n)) throw new Error(`${kind} not bound: ${n}`);
219
- return n;
220
- };
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
+ };
221
253
  const requireKv = bound(bindings.kv, "KV namespace");
222
254
  const requireD1 = bound(bindings.d1, "D1 database");
223
255
  const requireR2 = bound(bindings.r2, "R2 bucket");
@@ -256,12 +288,19 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
256
288
  const started = performance.now();
257
289
  // SQLite hands back column->(string|number|null|blob) rows, i.e. a JSON object.
258
290
  const results = conn.query<JsonObject, SqlParam[]>(sql).all(...params);
259
- const m = conn.query<{ changes: number; last_row_id: number }, []>(
260
- "SELECT changes() AS changes, last_insert_rowid() AS last_row_id",
261
- ).get();
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();
262
296
  return {
263
297
  results,
264
- 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
+ },
265
304
  };
266
305
  };
267
306
 
@@ -323,7 +362,12 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
323
362
  }
324
363
  case "kv.list": {
325
364
  const { store, part } = storeFor("kv", requireKv(msg.ns));
326
- 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
+ };
327
371
  }
328
372
  case "secret.get": {
329
373
  const name = str(msg.name);
@@ -354,25 +398,32 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
354
398
  const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
355
399
  const body = str(msg.body);
356
400
  const etag = createHash("sha256").update(body).digest("hex");
357
- store.query(
358
- "INSERT INTO r2 (bucket, key, body, size, etag, uploaded, http_json, custom_json) VALUES (?1,?2,?3,?4,?5,?6,?7,?8) " +
359
- "ON CONFLICT (bucket, key) DO UPDATE SET body=?3, size=?4, etag=?5, uploaded=?6, http_json=?7, custom_json=?8",
360
- ).run(
361
- bucket,
362
- str(msg.key),
363
- body,
364
- Buffer.byteLength(body),
365
- etag,
366
- new Date().toISOString(),
367
- JSON.stringify(msg.httpMetadata ?? {}),
368
- JSON.stringify(msg.customMetadata ?? {}),
369
- );
370
- 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
+ };
371
420
  }
372
421
  case "r2.get":
373
422
  case "r2.head": {
374
423
  const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
375
- 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));
376
427
  if (!row) return { ok: true, found: false };
377
428
  return { ok: true, found: true, object: r2Row(row), body: msg.op === "r2.get" ? row.body : undefined };
378
429
  }
@@ -386,9 +437,11 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
386
437
  const prefix = str(msg.prefix);
387
438
  const cursor = str(msg.cursor);
388
439
  const limit = Math.min(Math.max(Number(msg.limit) || 1000, 1), 1000);
389
- const rows = store.query<R2Row, [string, string, string, number]>(
390
- "SELECT * FROM r2 WHERE bucket = ? AND key LIKE ? || '%' AND key > ? ORDER BY key LIMIT ?",
391
- ).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);
392
445
  const truncated = rows.length > limit;
393
446
  const page = truncated ? rows.slice(0, limit) : rows;
394
447
  return {
@@ -402,7 +455,9 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
402
455
  case "queue.send": {
403
456
  const { store, part: q } = storeFor("queue", requireQueue(msg.queue));
404
457
  const at = Date.now() + Math.max(0, Number(msg.delaySeconds) || 0) * 1000;
405
- 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);
406
461
  return { ok: true };
407
462
  }
408
463
  case "queue.send_batch": {
@@ -410,7 +465,8 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
410
465
  const msgs = Array.isArray(msg.messages) ? msg.messages.map((m) => jsonObject(m) ?? {}) : [];
411
466
  const ins = store.query("INSERT INTO mq (queue, id, body, visible_at) VALUES (?, ?, ?, ?)");
412
467
  store.transaction(() => {
413
- 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);
414
470
  })();
415
471
  return { ok: true, count: msgs.length };
416
472
  }
@@ -431,9 +487,11 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
431
487
  // query it via the SQL API). Exposed here so a dashboard can read back.
432
488
  const ds = requireAe(msg.dataset);
433
489
  const limit = Math.min(Math.max(Number(msg.limit) || 20, 1), 200);
434
- const rows = db.query<{ ts: number; indexes_json: string; blobs_json: string; doubles_json: string }, [string, number]>(
435
- "SELECT ts, indexes_json, blobs_json, doubles_json FROM ae WHERE dataset = ? ORDER BY ts DESC, rowid DESC LIMIT ?",
436
- ).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);
437
495
  const total = db.query<{ n: number }, [string]>("SELECT count(*) AS n FROM ae WHERE dataset = ?").get(ds);
438
496
  return {
439
497
  ok: true,
@@ -449,9 +507,11 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
449
507
 
450
508
  case "do.storage.get": {
451
509
  const cls = requireDoClass(msg.cls);
452
- const row = db.query<{ value: string }, [string, string, string]>(
453
- "SELECT value FROM do_storage WHERE cls = ? AND id = ? AND key = ?",
454
- ).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));
455
515
  return row ? { ok: true, found: true, value: row.value } : { ok: true, found: false };
456
516
  }
457
517
  case "do.storage.put":
@@ -461,9 +521,9 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
461
521
  ).run(requireDoClass(msg.cls), str(msg.id), str(msg.key), str(msg.value));
462
522
  return { ok: true };
463
523
  case "do.storage.delete": {
464
- const r = db.query("DELETE FROM do_storage WHERE cls = ? AND id = ? AND key = ?").run(
465
- requireDoClass(msg.cls), str(msg.id), str(msg.key),
466
- );
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));
467
527
  return { ok: true, deleted: r.changes > 0 };
468
528
  }
469
529
  case "do.storage.delete_all":
@@ -472,9 +532,11 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
472
532
  case "do.storage.list": {
473
533
  const cls = requireDoClass(msg.cls);
474
534
  const limit = Math.min(Math.max(Number(msg.limit) || 1000, 1), 10000);
475
- const rows = db.query<{ key: string; value: string }, [string, string, string, number]>(
476
- "SELECT key, value FROM do_storage WHERE cls = ? AND id = ? AND key LIKE ? || '%' ORDER BY key LIMIT ?",
477
- ).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);
478
540
  return { ok: true, entries: rows.map((r) => [r.key, r.value]) };
479
541
  }
480
542
 
@@ -487,7 +549,8 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
487
549
  const nfh = assetManifest?.notFound ?? "none";
488
550
  if (nfh === "single-page-application") {
489
551
  const shell = readAsset("/index.html");
490
- 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 };
491
554
  }
492
555
  if (nfh === "404-page") {
493
556
  const page = readAsset("/404.html");
@@ -540,9 +603,11 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
540
603
  // `store`/`part` route the rows to this queue's backing file (#74); the
541
604
  // trigger payload still carries the binding name the sprout matches on.
542
605
  const { store, part } = storeFor("queue", binding);
543
- const rows = store.query<{ id: string; body: string; attempts: number }, [string, number, number]>(
544
- "SELECT id, body, attempts FROM mq WHERE queue = ? AND dead = 0 AND visible_at <= ? ORDER BY visible_at LIMIT ?",
545
- ).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);
546
611
  if (rows.length === 0) continue;
547
612
  // hide the batch so the next tick doesn't re-deliver it while in flight
548
613
  const hideUntil = now + 30_000;
@@ -561,9 +626,12 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
561
626
  const ids = (value: JsonValue | undefined) => (Array.isArray(value) ? value.filter(isString) : null);
562
627
  ack = (parsed && ids(parsed.ack)) ?? ack;
563
628
  retry = (parsed && ids(parsed.retry)) ?? [];
564
- } catch { /* keep defaults */ }
629
+ } catch {
630
+ /* keep defaults */
631
+ }
565
632
  } else {
566
- ack = []; retry = rows.map((r) => r.id); // delivery failed → retry all
633
+ ack = [];
634
+ retry = rows.map((r) => r.id); // delivery failed → retry all
567
635
  }
568
636
  const del = store.query("DELETE FROM mq WHERE id = ?");
569
637
  for (const id of ack) del.run(id);
@@ -579,15 +647,17 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
579
647
  if (bindings.queues.length > 0) timers.push(setInterval(drainQueuesOnce, 500));
580
648
  if (bindings.crons.length > 0) {
581
649
  let lastTick = "";
582
- timers.push(setInterval(() => {
583
- const now = new Date();
584
- const stamp = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}-${now.getUTCHours()}-${now.getUTCMinutes()}`;
585
- if (stamp === lastTick) return; // once per minute
586
- lastTick = stamp;
587
- for (const expr of bindings.crons) {
588
- if (cronMatches(expr, now)) void deliverTrigger("scheduled", { cron: expr, scheduledTime: now.getTime() });
589
- }
590
- }, 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
+ );
591
661
  }
592
662
  }
593
663
 
@@ -610,7 +680,13 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
610
680
  export function cronMatches(expr: string, when: Date): boolean {
611
681
  const parts = expr.trim().split(/\s+/);
612
682
  if (parts.length !== 5) return false;
613
- 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
+ ];
614
690
  const inField = (spec: string, value: number): boolean =>
615
691
  spec.split(",").some((token) => {
616
692
  if (token === "*") return true;
@@ -699,5 +775,7 @@ if (import.meta.main) {
699
775
  assetsDir: values["assets-dir"],
700
776
  });
701
777
  const { port } = listen(broker, "127.0.0.1", Number(values.port ?? process.env.SB_BROKER_PORT ?? 0));
702
- 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
+ );
703
781
  }
package/src/build.ts CHANGED
@@ -4,7 +4,14 @@ 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, DEPLOY_TARGET, hostTarget, 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 = {
@@ -41,7 +48,7 @@ function digest(value: Uint8Array | string): `sha256:${string}` {
41
48
  * does not come up.
42
49
  */
43
50
  export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
44
- const source = input.source ?? await readFile(input.sourcePath);
51
+ const source = input.source ?? (await readFile(input.sourcePath));
45
52
  const sourceHash = digest(source);
46
53
  const artifactId = sourceHash.slice("sha256:".length, 24);
47
54
  const artifactDir = resolve(input.projectDir, ".sproutboat/dist", artifactId);
@@ -72,6 +79,11 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
72
79
  do: Object.entries(input.config.durable_objects ?? {}).map(([binding, className]) => ({ binding, className })),
73
80
  crons: input.config.triggers?.crons ?? [],
74
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 ?? {},
75
87
  resources,
76
88
  };
77
89
 
@@ -109,8 +121,10 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
109
121
  // frozen at v2. The control plane reads this to configure the per-deployment
110
122
  // broker (KV / D1 / R2 / queue names, secret names, outbound allowlist, cron
111
123
  // schedules, Durable Object classes).
112
- const hasBindings = Object.values(bindings).some((value) => Array.isArray(value) && value.length > 0)
113
- || 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;
114
128
  if (hasBindings) {
115
129
  await writeFile(resolve(artifactDir, "bindings.json"), `${JSON.stringify(bindings, null, 2)}\n`);
116
130
  }
@@ -121,7 +135,11 @@ export async function buildArtifact(input: BuildInput): Promise<BuildOutput> {
121
135
  if (input.config.assets) {
122
136
  const srcDir = resolve(input.projectDir, input.config.assets.directory);
123
137
  const outDir = resolve(artifactDir, "assets");
124
- 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
+ ) {
125
143
  throw new Error(`assets.directory "${input.config.assets.directory}" not found — run your site build first`);
126
144
  }
127
145
  await cp(srcDir, outDir, { recursive: true });
package/src/bundle.ts CHANGED
@@ -30,7 +30,8 @@ const entryLabel = (entryPath: string, projectDir: string): string => relative(p
30
30
  /** Bun reports resolution failures on `AggregateError.errors`; its own message is just "Bundle failed". */
31
31
  function bundleDetail(cause: unknown): string {
32
32
  const errors = cause instanceof AggregateError ? cause.errors : [];
33
- if (errors.length > 0) return errors.map((error) => ` ${error instanceof Error ? error.message : String(error)}`).join("\n");
33
+ if (errors.length > 0)
34
+ return errors.map((error) => ` ${error instanceof Error ? error.message : String(error)}`).join("\n");
34
35
  return ` ${cause instanceof Error ? cause.message : String(cause)}`;
35
36
  }
36
37
 
package/src/compile.ts CHANGED
@@ -51,13 +51,15 @@ export async function compileSprout(input: CompileInput): Promise<void> {
51
51
  const haveGit = Bun.which("git");
52
52
  const haveMake = Bun.which("make");
53
53
  if (haveGit && haveMake) {
54
- console.warn(`prebuilt uWebSockets unusable (${error.message.split("\n")[0]}); falling back to git + make (slower, one-time)`);
54
+ console.warn(
55
+ `prebuilt uWebSockets unusable (${error.message.split("\n")[0]}); falling back to git + make (slower, one-time)`,
56
+ );
55
57
  } else {
56
58
  const missing = [!haveGit && "git", !haveMake && "make"].filter(Boolean).join(" and ");
57
59
  throw new Error(
58
60
  `${error.message}\n\nThe prebuilt uWebSockets is unusable, and ${missing} ` +
59
- `${missing.includes("and") ? "are" : "is"} not on PATH for the fallback build. ` +
60
- `Install ${missing}, or set SPROUTBOAT_UWS_TARBALL to a valid archive.`,
61
+ `${missing.includes("and") ? "are" : "is"} not on PATH for the fallback build. ` +
62
+ `Install ${missing}, or set SPROUTBOAT_UWS_TARBALL to a valid archive.`,
61
63
  );
62
64
  }
63
65
  }
@@ -95,7 +97,10 @@ export async function compileSprout(input: CompileInput): Promise<void> {
95
97
  { cwd: outDir, stdout: "pipe", stderr: "pipe", env: { ...process.env, PATH: path } },
96
98
  );
97
99
  let timedOut = false;
98
- const timer = setTimeout(() => { timedOut = true; child.kill(); }, COMPILE_TIMEOUT_MS);
100
+ const timer = setTimeout(() => {
101
+ timedOut = true;
102
+ child.kill();
103
+ }, COMPILE_TIMEOUT_MS);
99
104
  const [code, stdout, stderr] = await Promise.all([
100
105
  child.exited,
101
106
  new Response(child.stdout).text(),
package/src/config.ts CHANGED
@@ -11,7 +11,9 @@ export type ResourceRef = string | ResourceBinding;
11
11
 
12
12
  /** Normalizes a storage-binding array to `{ binding, id? }` rows. */
13
13
  export function resourceRefs(field: readonly ResourceRef[] | undefined): Array<{ binding: string; id?: string }> {
14
- return (field ?? []).map((entry) => (isString(entry) ? { binding: entry } : { binding: entry.binding, id: entry.id }));
14
+ return (field ?? []).map((entry) =>
15
+ isString(entry) ? { binding: entry } : { binding: entry.binding, id: entry.id },
16
+ );
15
17
  }
16
18
 
17
19
  /**
@@ -73,9 +75,7 @@ export type AssetsConfig = {
73
75
  run_sprout_first?: boolean | string[];
74
76
  };
75
77
 
76
- export type ConfigValidation =
77
- | { ok: true; value: SproutboatConfig }
78
- | { ok: false; errors: string[] };
78
+ export type ConfigValidation = { ok: true; value: SproutboatConfig } | { ok: false; errors: string[] };
79
79
 
80
80
  type JsonValue = string | number | boolean | null | ConfigJsonObject | JsonValue[];
81
81
 
@@ -86,8 +86,7 @@ interface ConfigJsonObject {
86
86
  type ConfigInput = JsonValue | undefined;
87
87
 
88
88
  function isRecord(value: ConfigInput): value is ConfigJsonObject {
89
- return value !== null && Object(value) === value && !Array.isArray(value)
90
- && !(value instanceof Function);
89
+ return value !== null && Object(value) === value && !Array.isArray(value) && !(value instanceof Function);
91
90
  }
92
91
 
93
92
  function isString(value: ConfigInput): value is string {
@@ -102,9 +101,21 @@ function validateConfig(value: ConfigInput): ConfigValidation {
102
101
  const errors: string[] = [];
103
102
  if (!isRecord(value)) return { ok: false, errors: ["config must be an object"] };
104
103
  const allowed = new Set([
105
- "$schema", "name", "main", "compatibility_date", "vars",
106
- "kv_namespaces", "secrets", "outbound", "d1_databases", "r2_buckets",
107
- "queues", "analytics_engine_datasets", "durable_objects", "triggers", "assets",
104
+ "$schema",
105
+ "name",
106
+ "main",
107
+ "compatibility_date",
108
+ "vars",
109
+ "kv_namespaces",
110
+ "secrets",
111
+ "outbound",
112
+ "d1_databases",
113
+ "r2_buckets",
114
+ "queues",
115
+ "analytics_engine_datasets",
116
+ "durable_objects",
117
+ "triggers",
118
+ "assets",
108
119
  ]);
109
120
  for (const key of Object.keys(value)) if (!allowed.has(key)) errors.push(`unsupported config field: ${key}`);
110
121
  const name = isString(value.name) && isProjectSlug(value.name) ? value.name : null;
@@ -115,7 +126,10 @@ function validateConfig(value: ConfigInput): ConfigValidation {
115
126
  if (main === null) {
116
127
  errors.push("main must be a relative entry point under src/");
117
128
  }
118
- const compatibility_date = isString(value.compatibility_date) && /^\d{4}-\d{2}-\d{2}$/.test(value.compatibility_date) ? value.compatibility_date : null;
129
+ const compatibility_date =
130
+ isString(value.compatibility_date) && /^\d{4}-\d{2}-\d{2}$/.test(value.compatibility_date)
131
+ ? value.compatibility_date
132
+ : null;
119
133
  if (compatibility_date === null) {
120
134
  errors.push("compatibility_date must use YYYY-MM-DD");
121
135
  }
@@ -127,7 +141,8 @@ function validateConfig(value: ConfigInput): ConfigValidation {
127
141
  else {
128
142
  vars = {};
129
143
  for (const [key, item] of Object.entries(value.vars)) {
130
- if (!/^[A-Z][A-Z0-9_]*$/.test(key) || !isString(item)) errors.push(`vars.${key} must be a string environment name`);
144
+ if (!/^[A-Z][A-Z0-9_]*$/.test(key) || !isString(item))
145
+ errors.push(`vars.${key} must be a string environment name`);
131
146
  else vars[key] = item;
132
147
  }
133
148
  }
@@ -174,9 +189,14 @@ function validateConfig(value: ConfigInput): ConfigValidation {
174
189
  if (isString(entry)) {
175
190
  if (bindingName.test(entry)) out.push(entry);
176
191
  else errors.push(`${field}: "${entry}" must be an UPPER_SNAKE binding name`);
177
- } else if (isRecord(entry) && isString(entry.binding) && isString(entry.id)
178
- && bindingName.test(entry.binding) && idPattern.test(entry.id)
179
- && Object.keys(entry).every((key) => key === "binding" || key === "id")) {
192
+ } else if (
193
+ isRecord(entry) &&
194
+ isString(entry.binding) &&
195
+ isString(entry.id) &&
196
+ bindingName.test(entry.binding) &&
197
+ idPattern.test(entry.id) &&
198
+ Object.keys(entry).every((key) => key === "binding" || key === "id")
199
+ ) {
180
200
  out.push({ binding: entry.binding, id: entry.id });
181
201
  } else {
182
202
  errors.push(`${field} entries must be an UPPER_SNAKE name or { binding: "NAME", id: "${kind}_…" }`);
@@ -189,7 +209,11 @@ function validateConfig(value: ConfigInput): ConfigValidation {
189
209
  const outbound = stringArray("outbound", hostPattern, "hostnames");
190
210
  // Analytics Engine datasets aren't provisioned — the dataset name springs into
191
211
  // existence on first writeDataPoint(), so there's no resource id to bind (#74).
192
- const analytics_engine_datasets = stringArray("analytics_engine_datasets", bindingName, "binding names (UPPER_SNAKE_CASE)");
212
+ const analytics_engine_datasets = stringArray(
213
+ "analytics_engine_datasets",
214
+ bindingName,
215
+ "binding names (UPPER_SNAKE_CASE)",
216
+ );
193
217
  const kv_namespaces = resourceArray("kv_namespaces", "kv");
194
218
  const d1_databases = resourceArray("d1_databases", "d1");
195
219
  const r2_buckets = resourceArray("r2_buckets", "r2");
@@ -198,7 +222,7 @@ function validateConfig(value: ConfigInput): ConfigValidation {
198
222
  let durable_objects: Record<string, string> | undefined;
199
223
  if (value.durable_objects !== undefined) {
200
224
  if (!isRecord(value.durable_objects)) {
201
- errors.push("durable_objects must be an object of { BINDING_NAME: \"ClassName\" }");
225
+ errors.push('durable_objects must be an object of { BINDING_NAME: "ClassName" }');
202
226
  } else {
203
227
  durable_objects = {};
204
228
  for (const [binding, className] of Object.entries(value.durable_objects)) {
@@ -230,15 +254,24 @@ function validateConfig(value: ConfigInput): ConfigValidation {
230
254
  errors.push("assets must be an object with a `directory`");
231
255
  } else {
232
256
  const raw = value.assets;
233
- const dir = isString(raw.directory) && raw.directory.length > 0 && !raw.directory.includes("..")
234
- ? raw.directory.replace(/^\.\//, "").replace(/\/$/, "")
235
- : null;
257
+ const dir =
258
+ isString(raw.directory) && raw.directory.length > 0 && !raw.directory.includes("..")
259
+ ? raw.directory.replace(/^\.\//, "").replace(/\/$/, "")
260
+ : null;
236
261
  if (dir === null) errors.push("assets.directory must be a project-relative path");
237
- const binding = raw.binding === undefined ? undefined
238
- : isString(raw.binding) && bindingName.test(raw.binding) ? raw.binding : null;
262
+ const binding =
263
+ raw.binding === undefined
264
+ ? undefined
265
+ : isString(raw.binding) && bindingName.test(raw.binding)
266
+ ? raw.binding
267
+ : null;
239
268
  if (binding === null) errors.push("assets.binding must be a binding name (UPPER_SNAKE_CASE)");
240
- const nfh = raw.not_found_handling === "none" || raw.not_found_handling === "single-page-application"
241
- || raw.not_found_handling === "404-page" ? raw.not_found_handling : undefined;
269
+ const nfh =
270
+ raw.not_found_handling === "none" ||
271
+ raw.not_found_handling === "single-page-application" ||
272
+ raw.not_found_handling === "404-page"
273
+ ? raw.not_found_handling
274
+ : undefined;
242
275
  if (raw.not_found_handling !== undefined && nfh === undefined) {
243
276
  errors.push('assets.not_found_handling must be "none", "single-page-application", or "404-page"');
244
277
  }
@@ -259,16 +292,22 @@ function validateConfig(value: ConfigInput): ConfigValidation {
259
292
  }
260
293
  }
261
294
 
262
- const resourceNames = (refs: ResourceRef[] | undefined): string[] =>
263
- resourceRefs(refs).map((ref) => ref.binding);
295
+ const resourceNames = (refs: ResourceRef[] | undefined): string[] => resourceRefs(refs).map((ref) => ref.binding);
264
296
  const bindingSlots = [
265
- ...resourceNames(kv_namespaces), ...(secrets ?? []), ...resourceNames(d1_databases), ...resourceNames(r2_buckets),
266
- ...resourceNames(queues), ...(analytics_engine_datasets ?? []), ...Object.keys(durable_objects ?? {}), ...Object.keys(vars ?? {}),
297
+ ...resourceNames(kv_namespaces),
298
+ ...(secrets ?? []),
299
+ ...resourceNames(d1_databases),
300
+ ...resourceNames(r2_buckets),
301
+ ...resourceNames(queues),
302
+ ...(analytics_engine_datasets ?? []),
303
+ ...Object.keys(durable_objects ?? {}),
304
+ ...Object.keys(vars ?? {}),
267
305
  ...(assets?.binding ? [assets.binding] : []),
268
306
  ];
269
307
  if (new Set(bindingSlots).size !== bindingSlots.length) errors.push("vars and binding names must not collide");
270
308
 
271
- if (errors.length || name === null || main === null || compatibility_date === null || schema === null) return { ok: false, errors };
309
+ if (errors.length || name === null || main === null || compatibility_date === null || schema === null)
310
+ return { ok: false, errors };
272
311
  const config: SproutboatConfig = { name, main, compatibility_date };
273
312
  if ("$schema" in value) config.$schema = schema;
274
313
  if ("vars" in value) config.vars = vars;
@@ -307,6 +346,9 @@ export function parseConfig(source: string): ConfigValidation {
307
346
  json = json.replace(/,\s*([}\]])/g, "$1");
308
347
  return validateConfig(JSON.parse(json));
309
348
  } catch (error) {
310
- return { ok: false, errors: [`invalid sproutboat.jsonc: ${error instanceof Error ? error.message : String(error)}`] };
349
+ return {
350
+ ok: false,
351
+ errors: [`invalid sproutboat.jsonc: ${error instanceof Error ? error.message : String(error)}`],
352
+ };
311
353
  }
312
354
  }