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/README.md +74 -55
- package/SURFACE.md +13 -7
- package/package.json +28 -20
- package/src/assets.ts +29 -9
- package/src/broker.ts +188 -87
- package/src/build.ts +42 -7
- package/src/bundle.ts +70 -0
- package/src/compile.ts +37 -9
- package/src/config.ts +72 -30
- package/src/credentials.ts +19 -2
- package/src/dev.ts +213 -0
- package/src/json.ts +38 -0
- package/src/main.ts +473 -126
- package/src/manifest.ts +81 -14
- package/src/native-fetch-prelude.js +274 -135
- package/src/patch-porffor.ts +5 -2
- package/src/report.ts +39 -17
- package/src/source.ts +20 -2
- package/src/style.ts +9 -6
- package/src/surface.ts +195 -42
- package/src/toolchain.ts +21 -7
- package/src/update-check.ts +33 -9
- package/src/wrap.ts +71 -17
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
|
-
|
|
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?:
|
|
78
|
+
fetchImpl?: FetchLike;
|
|
73
79
|
};
|
|
74
80
|
|
|
75
81
|
type SqlParam = string | number | null;
|
|
76
|
-
|
|
77
|
-
|
|
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 ||
|
|
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:
|
|
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 = {
|
|
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))
|
|
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(
|
|
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(
|
|
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]>(
|
|
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 =
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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:
|
|
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[]):
|
|
287
|
+
const d1Run = (conn: Database, sql: string, params: SqlParam[]): D1Result => {
|
|
243
288
|
const started = performance.now();
|
|
244
|
-
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
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: {
|
|
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:
|
|
260
|
-
customMetadata:
|
|
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
|
|
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 {
|
|
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) ?
|
|
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
|
|
344
|
-
|
|
345
|
-
"
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
|
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
|
|
376
|
-
|
|
377
|
-
|
|
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
|
|
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) ?
|
|
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)
|
|
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
|
|
421
|
-
|
|
422
|
-
|
|
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:
|
|
430
|
-
blobs:
|
|
431
|
-
doubles:
|
|
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
|
|
439
|
-
|
|
440
|
-
|
|
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
|
|
451
|
-
|
|
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
|
|
462
|
-
|
|
463
|
-
|
|
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)
|
|
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
|
-
|
|
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:
|
|
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
|
|
528
|
-
|
|
529
|
-
|
|
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.
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
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 = [];
|
|
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(
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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 = [
|
|
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):
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
96
|
-
|
|
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 (
|
|
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 });
|