sproutboat 0.5.0 → 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/README.md +67 -78
- package/SURFACE.md +1 -1
- package/package.json +25 -20
- package/src/assets.ts +29 -9
- package/src/broker.ts +143 -65
- package/src/build.ts +23 -5
- package/src/bundle.ts +2 -1
- package/src/compile.ts +9 -4
- package/src/config.ts +71 -29
- package/src/dev.ts +33 -16
- package/src/json.ts +9 -1
- package/src/main.ts +274 -77
- package/src/manifest.ts +63 -14
- package/src/native-fetch-prelude.js +256 -127
- package/src/patch-porffor.ts +5 -2
- package/src/report.ts +37 -17
- package/src/source.ts +4 -1
- package/src/style.ts +9 -6
- package/src/surface.ts +150 -42
- package/src/toolchain.ts +15 -4
- package/src/update-check.ts +20 -5
- package/src/wrap.ts +32 -8
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 = {
|
|
84
|
-
|
|
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 = {
|
|
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(
|
|
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(
|
|
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]>(
|
|
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 =
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
260
|
-
|
|
261
|
-
|
|
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: {
|
|
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 {
|
|
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
|
|
358
|
-
|
|
359
|
-
"
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
|
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
|
|
390
|
-
|
|
391
|
-
|
|
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
|
|
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)
|
|
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
|
|
435
|
-
|
|
436
|
-
|
|
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
|
|
453
|
-
|
|
454
|
-
|
|
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
|
|
465
|
-
|
|
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
|
|
476
|
-
|
|
477
|
-
|
|
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)
|
|
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
|
|
544
|
-
|
|
545
|
-
|
|
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 {
|
|
629
|
+
} catch {
|
|
630
|
+
/* keep defaults */
|
|
631
|
+
}
|
|
565
632
|
} else {
|
|
566
|
-
ack = [];
|
|
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(
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
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 = [
|
|
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(
|
|
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 {
|
|
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 =
|
|
113
|
-
|
|
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 (
|
|
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)
|
|
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(
|
|
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
|
-
|
|
60
|
-
|
|
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(() => {
|
|
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) =>
|
|
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",
|
|
106
|
-
"
|
|
107
|
-
"
|
|
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 =
|
|
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))
|
|
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 (
|
|
178
|
-
|
|
179
|
-
|
|
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(
|
|
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(
|
|
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 =
|
|
234
|
-
|
|
235
|
-
|
|
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 =
|
|
238
|
-
|
|
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 =
|
|
241
|
-
|
|
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),
|
|
266
|
-
...
|
|
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)
|
|
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 {
|
|
349
|
+
return {
|
|
350
|
+
ok: false,
|
|
351
|
+
errors: [`invalid sproutboat.jsonc: ${error instanceof Error ? error.message : String(error)}`],
|
|
352
|
+
};
|
|
311
353
|
}
|
|
312
354
|
}
|