sproutboat 0.9.0 → 0.10.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
@@ -1,1182 +1,4 @@
1
- #!/usr/bin/env bun
2
- /**
3
- * Per-deployment binding broker.
4
- *
5
- * A native-fetch sprout has no syscalls of its own beyond an inbound HTTP
6
- * server; the prelude's inline-C transport opens a loopback TCP connection to
7
- * this process for every `env.<KV>` / `env.<SECRET>` / `fetch()` call. The
8
- * supervisor starts one broker per deployment, on its own loopback port, and
9
- * passes `SB_BROKER_PORT` + `SB_BROKER_TOKEN` to the sprout next to `$PORT`.
10
- *
11
- * Wire frame (both directions): [u32 LE length][payload].
12
- * request payload : "<token>\n<json>"
13
- * reply payload : "<json>"
14
- *
15
- * ponytail: one SQLite file for KV, secrets from a plain JSON file, fetch
16
- * allowlisted by exact host. Encryption at rest, per-key TTLs, redirect
17
- * re-validation and private-IP blocking are v2 — the exact-host allowlist is
18
- * the only SSRF control today.
19
- */
20
- import { Database, type Statement } from "bun:sqlite";
21
- import { createHash } from "node:crypto";
22
- import { existsSync, mkdirSync, readFileSync } from "node:fs";
23
- import { dirname, join, normalize, resolve } from "node:path";
24
- import { parseArgs } from "node:util";
25
- import { resolveAssetKey, type AssetManifest } from "./assets";
26
- import { isSafeInteger } from "./json";
27
- import { isBoolean, isString, jsonObject, parseJsonValue, type JsonObject, type JsonValue } from "./json";
28
-
29
- export type Bindings = {
30
- kv: string[];
31
- secrets: string[];
32
- outbound: string[];
33
- d1: string[];
34
- r2: string[];
35
- queues: string[];
36
- analytics: string[];
37
- do: Array<{ binding: string; className: string }>;
38
- /** #48 — worker-to-worker: binding name -> the project it calls. */
39
- services: Array<{ binding: string; service: string }>;
40
- crons: string[];
41
- /** Static-asset binding name, or `""` when assets are edge-only. */
42
- assets: string;
43
- /**
44
- * #74 — binding name -> account-level resource it resolves to. A binding with
45
- * an entry here stores in a per-resource SQLite file under `resourceDir`,
46
- * keyed by `id`, shared across this owner's deployments; a binding without one
47
- * falls back to the per-broker `db` partitioned by its own name (local dev).
48
- */
49
- resources: Record<string, { kind: "kv" | "d1" | "r2" | "queue"; id: string }>;
50
- };
51
- /** One decoded wire frame. `undefined` fields are dropped by `JSON.stringify`, so
52
- * an optional reply field can be left off without a second object shape. */
53
- export type Frame = { [key: string]: JsonValue | undefined };
54
-
55
- /** The only shape of `fetch` the broker calls — the global `fetch` satisfies it. */
56
- export type FetchLike = (input: URL | string, init?: RequestInit) => Promise<Response>;
57
-
58
- export type BrokerOptions = {
59
- db?: string;
60
- /** Directory for per-D1-binding SQLite files. Defaults to `<dirname(db)>/d1`, or in-memory when `db` is `:memory:`. */
61
- dataDir?: string;
62
- /**
63
- * #48 — where to send a service-binding call: the node's edge, on loopback.
64
- * Forwarding through the edge rather than dialling the target sprout directly
65
- * reuses everything the request path already does — routing, the process
66
- * pool, cold start, metrics, logs — and means the broker needs no knowledge
67
- * of where a deployment is running.
68
- */
69
- edgeUrl?: string;
70
- /**
71
- * #48 — binding name -> the target's hostname, resolved by the control plane
72
- * at activation. Not part of the artifact: the same binary deploys to boxes
73
- * with different domains, and a target can be redeployed without rebuilding
74
- * its callers.
75
- */
76
- services?: Record<string, string>;
77
- /**
78
- * #74 — directory holding one `<resource-id>.sqlite` per account-level KV / R2 /
79
- * queue / D1 resource. Defaults to `<dirname(db)>/resources`, in-memory when
80
- * `db` is `:memory:`. The supervisor points this at an owner-stable path so the
81
- * data outlives a redeploy.
82
- */
83
- resourceDir?: string;
84
- token?: string;
85
- bindings?: Partial<Bindings>;
86
- secrets?: Record<string, string>;
87
- /**
88
- * `http://127.0.0.1:<PORT>` of this deployment's sprout. When set, the broker
89
- * runs the cron scheduler and the queue consumer, delivering to the sprout
90
- * with an `x-sb-trigger` header authenticated by `token`.
91
- */
92
- sproutUrl?: string;
93
- /** Directory of published static assets (its sibling `assets.json` is the manifest). Backs `assets.get`. */
94
- assetsDir?: string;
95
- /** Injected in tests; defaults to the global `fetch`. */
96
- fetchImpl?: FetchLike;
97
- };
98
-
99
- type SqlParam = string | number | null;
100
- /** The `{ results, meta }` shape Cloudflare's D1 client expects back per statement. */
101
- type D1Result = {
102
- results: JsonValue[];
103
- meta: { duration: number; changes: number; last_row_id: number; rows_read: number };
104
- };
105
- type R2Row = {
106
- key: string;
107
- /** TEXT from a v0 write, BLOB from a v1 one; sqlite hands each back as-is. */
108
- body: string | Uint8Array;
109
- size: number;
110
- etag: string;
111
- uploaded: string;
112
- http_json: string;
113
- custom_json: string;
114
- };
115
- const isNumber = (v: JsonValue | undefined): v is number => Number.isFinite(v);
116
- const sqlParams = (v: JsonValue | undefined): SqlParam[] => {
117
- if (!Array.isArray(v)) return [];
118
- return v.map((p) => (p === null || isNumber(p) || isString(p) ? p : isBoolean(p) ? (p ? 1 : 0) : String(p)));
119
- };
120
-
121
- // One binding call = one frame, and an R2/KV value travels inside it as a JSON
122
- // string (escaping can inflate binary content several ×). 32 MiB keeps a ~25 MB
123
- // upload working; true large-object streaming is v2.
124
- const MAX_FRAME = 32 * 1024 * 1024;
125
- const str = (v: JsonValue | undefined): string => (isString(v) ? v : String(v ?? ""));
126
-
127
- export type Broker = {
128
- /** Run one parsed request object through the op dispatch. */
129
- dispatch(msg: Frame): Promise<Frame>;
130
- /** Verify the token line and dispatch a raw "<token>\n<json>" payload. */
131
- handlePayload(payload: string): Promise<Frame>;
132
- /**
133
- * #63 — one wire frame in, one out, binary included.
134
- *
135
- * v0 payloads are `"<token>\n<json>"` and stay exactly as they were: a
136
- * deployment built before this existed keeps working against a newer broker.
137
- * v1 starts with a 0x01 marker and carries `[u32 LE json length][json][bytes]`,
138
- * so an object body never has to be escaped into the JSON. A token's first
139
- * character is never 0x01, which is what makes the two tellable apart without
140
- * negotiation.
141
- */
142
- handleFrame(payload: Buffer): Promise<Buffer>;
143
- close(): void;
144
- };
145
-
146
- /** First byte of a v1 payload. */
147
- export const FRAME_V1 = 1;
148
-
149
- /** Wrap a payload in the [u32 LE length] header every frame carries. */
150
- export function frameOf(payload: Buffer): Buffer {
151
- const out = Buffer.allocUnsafe(4 + payload.length);
152
- out.writeUInt32LE(payload.length, 0);
153
- payload.copy(out, 4);
154
- return out;
155
- }
156
-
157
- /** Build a v1 payload: marker, json length, json, then the bytes. */
158
- export function encodeV1(json: Frame, binary?: Uint8Array): Buffer {
159
- const body = Buffer.from(JSON.stringify(json), "utf8");
160
- const bin = binary ?? new Uint8Array(0);
161
- const out = Buffer.allocUnsafe(1 + 4 + body.length + bin.length);
162
- out.writeUInt8(FRAME_V1, 0);
163
- out.writeUInt32LE(body.length, 1);
164
- body.copy(out, 5);
165
- if (bin.length) Buffer.from(bin.buffer, bin.byteOffset, bin.length).copy(out, 5 + body.length);
166
- return out;
167
- }
168
-
169
- export function createBroker(opts: BrokerOptions = {}): Broker {
170
- const bindings: Bindings = {
171
- kv: [],
172
- secrets: [],
173
- outbound: [],
174
- d1: [],
175
- r2: [],
176
- queues: [],
177
- analytics: [],
178
- do: [],
179
- services: [],
180
- crons: [],
181
- assets: "",
182
- resources: {},
183
- ...opts.bindings,
184
- };
185
- const secrets = opts.secrets ?? {};
186
-
187
- // Static assets: read the manifest once. Files are read from disk per request
188
- // (small, OS-cached); no need to hold bodies in memory.
189
- const assetsDir = opts.assetsDir ? resolve(opts.assetsDir) : null;
190
- let assetManifest: AssetManifest | null = null;
191
- if (assetsDir) {
192
- const manifestPath = join(dirname(assetsDir), "assets.json");
193
- if (existsSync(manifestPath)) {
194
- // SAFETY: assets.json sits beside the published assets dir and is written
195
- // only by `sproutboat build` from the AssetManifest contract.
196
- assetManifest = JSON.parse(readFileSync(manifestPath, "utf8")) as AssetManifest;
197
- }
198
- }
199
- const readAsset = (path: string): { type: string; hash: string; body: Uint8Array } | null => {
200
- if (!assetsDir || !assetManifest) return null;
201
- const entry = assetManifest.files[path];
202
- if (!entry) return null;
203
- // path keys are `/`-prefixed and `..`-free (walkAssets), but re-check.
204
- const abs = normalize(join(assetsDir, path));
205
- if (!abs.startsWith(assetsDir)) return null;
206
- return { type: entry.type, hash: entry.hash, body: readFileSync(abs) };
207
- };
208
- const token = opts.token ?? "";
209
- /** #63 §3 — replies to recent non-idempotent requests, keyed by op and id. */
210
- const replayed = new Map<string, Frame>();
211
- const serviceHosts = opts.services ?? {};
212
- const doFetch = opts.fetchImpl ?? fetch;
213
-
214
- const dbPath = opts.db ?? ":memory:";
215
- const inMemory = dbPath === ":memory:" || dbPath === "";
216
- const d1Dir = opts.dataDir ?? (inMemory ? null : join(dirname(resolve(dbPath)), "d1"));
217
- const resourceDir = opts.resourceDir ?? (inMemory ? null : join(dirname(resolve(dbPath)), "resources"));
218
-
219
- // WAL + NORMAL is the standard pairing: a write no longer fsyncs the WAL, so
220
- // host power loss can drop the last few committed txns, but a process crash
221
- // never can and the file never corrupts. Right trade for a single-VPS
222
- // KV/queue/DO store; on real block storage this is ~10-100x on writes.
223
- const openStore = (path: string): Database => {
224
- const conn = new Database(path, { create: true });
225
- conn.exec("PRAGMA journal_mode = WAL");
226
- conn.exec("PRAGMA synchronous = NORMAL");
227
- conn.exec(
228
- "CREATE TABLE IF NOT EXISTS kv (ns TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY (ns, key))",
229
- );
230
- conn.exec(
231
- "CREATE TABLE IF NOT EXISTS r2 (bucket TEXT NOT NULL, key TEXT NOT NULL, body TEXT NOT NULL, size INTEGER NOT NULL, " +
232
- "etag TEXT NOT NULL, uploaded TEXT NOT NULL, http_json TEXT NOT NULL DEFAULT '{}', custom_json TEXT NOT NULL DEFAULT '{}', " +
233
- "PRIMARY KEY (bucket, key))",
234
- );
235
- conn.exec(
236
- "CREATE TABLE IF NOT EXISTS mq (queue TEXT NOT NULL, id TEXT PRIMARY KEY, body TEXT NOT NULL, " +
237
- "visible_at INTEGER NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, dead INTEGER NOT NULL DEFAULT 0)",
238
- );
239
- return conn;
240
- };
241
-
242
- const db = openStore(dbPath);
243
- // Durable Object storage and Analytics Engine rows stay in the per-broker db —
244
- // neither is an account-level resource (#74).
245
- db.exec(
246
- "CREATE TABLE IF NOT EXISTS do_storage (cls TEXT NOT NULL, id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, " +
247
- "PRIMARY KEY (cls, id, key))",
248
- );
249
- db.exec(
250
- "CREATE TABLE IF NOT EXISTS ae (dataset TEXT NOT NULL, ts INTEGER NOT NULL, indexes_json TEXT NOT NULL, " +
251
- "blobs_json TEXT NOT NULL, doubles_json TEXT NOT NULL)",
252
- );
253
- // #125 — at most one pending alarm per object, which is the Workers rule: a
254
- // later setAlarm replaces the earlier one rather than queueing beside it.
255
- db.exec(
256
- "CREATE TABLE IF NOT EXISTS do_alarm (cls TEXT NOT NULL, id TEXT NOT NULL, at INTEGER NOT NULL, " +
257
- "attempts INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (cls, id))",
258
- );
259
-
260
- // #74 — one SQLite file per account-level resource id, opened on first use.
261
- const resourceDbs = new Map<string, Database>();
262
- const resourceDb = (id: string): Database => {
263
- let conn = resourceDbs.get(id);
264
- if (!conn) {
265
- if (resourceDir) mkdirSync(resourceDir, { recursive: true });
266
- conn = openStore(resourceDir ? join(resourceDir, `${id}.sqlite`) : ":memory:");
267
- resourceDbs.set(id, conn);
268
- }
269
- return conn;
270
- };
271
-
272
- /**
273
- * Where a KV / R2 / queue binding stores: its resource's own file keyed by
274
- * `id` when the config bound it to one, else the per-broker `db` partitioned
275
- * by the binding name (bare-string bindings / local dev).
276
- */
277
- const storeFor = (kind: "kv" | "r2" | "queue", binding: string): { store: Database; part: string } => {
278
- const resource = bindings.resources[binding];
279
- return resource && resource.kind === kind
280
- ? { store: resourceDb(resource.id), part: resource.id }
281
- : { store: db, part: binding };
282
- };
283
-
284
- type KvStmts = {
285
- get: Statement<{ value: string }, [string, string]>;
286
- put: Statement<unknown, [string, string, string]>;
287
- del: Statement<unknown, [string, string]>;
288
- list: Statement<{ key: string }, [string, string]>;
289
- };
290
- const kvStmtCache = new Map<Database, KvStmts>();
291
- const kvStmts = (store: Database): KvStmts => {
292
- let stmts = kvStmtCache.get(store);
293
- if (!stmts) {
294
- stmts = {
295
- get: store.query<{ value: string }, [string, string]>("SELECT value FROM kv WHERE ns = ? AND key = ?"),
296
- put: store.query(
297
- "INSERT INTO kv (ns, key, value) VALUES (?1, ?2, ?3) ON CONFLICT (ns, key) DO UPDATE SET value = ?3",
298
- ),
299
- del: store.query("DELETE FROM kv WHERE ns = ? AND key = ?"),
300
- list: store.query<{ key: string }, [string, string]>(
301
- "SELECT key FROM kv WHERE ns = ? AND key LIKE ? || '%' ORDER BY key LIMIT 1000",
302
- ),
303
- };
304
- kvStmtCache.set(store, stmts);
305
- }
306
- return stmts;
307
- };
308
-
309
- const bound =
310
- (list: string[], kind: string) =>
311
- (name: JsonValue | undefined): string => {
312
- const n = str(name);
313
- if (!list.includes(n)) throw new Error(`${kind} not bound: ${n}`);
314
- return n;
315
- };
316
- const requireKv = bound(bindings.kv, "KV namespace");
317
- const requireD1 = bound(bindings.d1, "D1 database");
318
- const requireR2 = bound(bindings.r2, "R2 bucket");
319
- const requireQueue = bound(bindings.queues, "queue");
320
- const requireAe = bound(bindings.analytics, "analytics dataset");
321
- const doClasses = new Set(bindings.do.map((d) => d.className));
322
- const requireDoClass = (cls: JsonValue | undefined): string => {
323
- const n = str(cls);
324
- if (!doClasses.has(n)) throw new Error(`Durable Object class not bound: ${n}`);
325
- return n;
326
- };
327
- const newId = () => createHash("sha256").update(`${Date.now()}:${Math.random()}`).digest("hex").slice(0, 24);
328
-
329
- // One SQLite database per bound D1 binding — independent SQL namespaces. A
330
- // binding wired to an account-level resource (#74) opens `<resource-id>.sqlite`
331
- // under `resourceDir` (survives redeploys); a bare-string binding keeps its
332
- // per-broker `<name>.sqlite` under `d1Dir`.
333
- const d1Conns = new Map<string, Database>();
334
- const d1 = (name: string): Database => {
335
- const resource = bindings.resources[name];
336
- const key = resource && resource.kind === "d1" ? resource.id : name;
337
- let conn = d1Conns.get(key);
338
- if (!conn) {
339
- const dir = resource && resource.kind === "d1" ? resourceDir : d1Dir;
340
- if (dir) mkdirSync(dir, { recursive: true });
341
- conn = new Database(dir ? join(dir, `${key}.sqlite`) : ":memory:", { create: true });
342
- conn.exec("PRAGMA journal_mode = WAL");
343
- conn.exec("PRAGMA synchronous = NORMAL");
344
- d1Conns.set(key, conn);
345
- }
346
- return conn;
347
- };
348
-
349
- // Run one statement, return CF-D1-shaped { results, meta }.
350
- const d1Run = (conn: Database, sql: string, params: SqlParam[]): D1Result => {
351
- const started = performance.now();
352
- // SQLite hands back column->(string|number|null|blob) rows, i.e. a JSON object.
353
- const results = conn.query<JsonObject, SqlParam[]>(sql).all(...params);
354
- const m = conn
355
- .query<{ changes: number; last_row_id: number }, []>(
356
- "SELECT changes() AS changes, last_insert_rowid() AS last_row_id",
357
- )
358
- .get();
359
- return {
360
- results,
361
- meta: {
362
- duration: performance.now() - started,
363
- changes: m?.changes ?? 0,
364
- last_row_id: m?.last_row_id ?? 0,
365
- rows_read: results.length,
366
- },
367
- };
368
- };
369
-
370
- /** An object body as bytes, whichever storage class it came back in. */
371
- const r2Bytes = (body: string | Uint8Array): Uint8Array =>
372
- body instanceof Uint8Array ? body : new TextEncoder().encode(body);
373
-
374
- /** An object body as text, for the v0 reply shape that carries it in JSON.
375
- * A binary object stored by a v1 client cannot survive this, which is why
376
- * v1 exists — but returning a decoded string beats returning `{"0":...}`. */
377
- const r2Text = (body: string | Uint8Array): string =>
378
- body instanceof Uint8Array ? new TextDecoder().decode(body) : body;
379
-
380
- const r2Row = (r: R2Row) => ({
381
- key: r.key,
382
- size: r.size,
383
- etag: r.etag,
384
- uploaded: r.uploaded,
385
- httpMetadata: parseJsonValue(r.http_json),
386
- customMetadata: parseJsonValue(r.custom_json),
387
- });
388
-
389
- /**
390
- * An outbound response body, refused past a cap.
391
- *
392
- * The size is the remote host's choice, and the body is held whole: measured
393
- * on a standalone binary, a 100 MB response took resident memory from 43 MB
394
- * to 321 MB. One allowlisted upstream having a bad day should not be able to
395
- * end the process. 32 MiB by default, and the same variable the embedded
396
- * transport reads so both backends agree.
397
- */
398
- async function readCapped(response: Response, host: string): Promise<string> {
399
- const cap = Number(process.env.SB_FETCH_MAX_BYTES) || 32 * 1024 * 1024;
400
- const declared = Number(response.headers.get("content-length") || 0);
401
- if (declared > cap) throw new Error(`response exceeds SB_FETCH_MAX_BYTES from ${host}`);
402
- const reader = response.body?.getReader();
403
- if (!reader) return "";
404
- const parts: Uint8Array[] = [];
405
- let total = 0;
406
- for (;;) {
407
- const { done, value } = await reader.read();
408
- if (done) break;
409
- total += value.byteLength;
410
- if (total > cap) {
411
- await reader.cancel();
412
- throw new Error(`response exceeds SB_FETCH_MAX_BYTES from ${host}`);
413
- }
414
- parts.push(value);
415
- }
416
- return new TextDecoder().decode(Buffer.concat(parts));
417
- }
418
-
419
- async function proxyFetch(msg: Frame): Promise<Frame> {
420
- let url: URL;
421
- try {
422
- url = new URL(str(msg.url));
423
- } catch {
424
- throw new Error(`invalid url: ${str(msg.url)}`);
425
- }
426
- if (url.protocol !== "https:" && url.protocol !== "http:") throw new Error(`unsupported protocol: ${url.protocol}`);
427
- if (!bindings.outbound.includes(url.host)) throw new Error(`host not in outbound allowlist: ${url.host}`);
428
-
429
- const headers = new Headers();
430
- if (Array.isArray(msg.headers)) {
431
- for (const pair of msg.headers) {
432
- if (Array.isArray(pair) && pair.length === 2) headers.set(str(pair[0]), str(pair[1]));
433
- }
434
- }
435
- const method = str(msg.method || "GET").toUpperCase();
436
- const res = await doFetch(url, {
437
- method,
438
- headers,
439
- body: msg.body == null || method === "GET" || method === "HEAD" ? undefined : str(msg.body),
440
- redirect: "manual",
441
- });
442
- const outHeaders: Array<[string, string]> = [];
443
- res.headers.forEach((v, k) => outHeaders.push([k, v]));
444
- return { ok: true, status: res.status, headers: outHeaders, body: await readCapped(res, url.host) };
445
- }
446
-
447
- /**
448
- * #48 — forward a service-binding call to the target deployment via the edge.
449
- *
450
- * The Host header is the whole routing decision: the edge matches a route by
451
- * hostname, so this is exactly the request the outside world would make,
452
- * minus the trip through the network (and therefore minus TLS and the
453
- * default-deny egress rules, which is why this is not `fetch`).
454
- */
455
- async function serviceFetch(msg: Frame): Promise<Frame> {
456
- const binding = str(msg.binding);
457
- const declared = bindings.services.find((entry) => entry.binding === binding);
458
- if (!declared) throw new Error(`service not bound: ${binding}`);
459
- const host = serviceHosts[binding];
460
- // Declared in the artifact but unresolved at activation: the target project
461
- // does not exist, or is not deployed yet. Say which, rather than 502.
462
- if (!host) throw new Error(`service ${binding} -> "${declared.service}" is not deployed on this control plane`);
463
- if (!opts.edgeUrl) throw new Error(`service ${binding} cannot be called: this broker has no edge url`);
464
-
465
- let path = "/";
466
- try {
467
- const parsed = new URL(str(msg.url));
468
- path = `${parsed.pathname}${parsed.search}`;
469
- } catch {
470
- path = str(msg.url) || "/";
471
- if (!path.startsWith("/")) path = `/${path}`;
472
- }
473
- const headers = new Headers();
474
- if (Array.isArray(msg.headers)) {
475
- for (const pair of msg.headers) {
476
- if (Array.isArray(pair) && pair.length === 2) headers.set(str(pair[0]), str(pair[1]));
477
- }
478
- }
479
- headers.set("host", host);
480
- const method = str(msg.method || "GET").toUpperCase();
481
- const target = new URL(path, opts.edgeUrl);
482
- const res = await doFetch(target, {
483
- method,
484
- headers,
485
- body: msg.body == null || method === "GET" || method === "HEAD" ? undefined : str(msg.body),
486
- redirect: "manual",
487
- });
488
- const outHeaders: Array<[string, string]> = [];
489
- res.headers.forEach((v, k) => outHeaders.push([k, v]));
490
- return { ok: true, status: res.status, headers: outHeaders, body: await readCapped(res, host) };
491
- }
492
-
493
- async function dispatch(msg: Frame): Promise<Frame> {
494
- switch (msg.op) {
495
- case "ping":
496
- return { ok: true, op: "pong", echo: msg.msg ?? null, pid: process.pid };
497
- case "kv.get": {
498
- const { store, part } = storeFor("kv", requireKv(msg.ns));
499
- const row = kvStmts(store).get.get(part, str(msg.key));
500
- return row ? { ok: true, found: true, value: row.value } : { ok: true, found: false, value: null };
501
- }
502
- case "kv.put": {
503
- const { store, part } = storeFor("kv", requireKv(msg.ns));
504
- kvStmts(store).put.run(part, str(msg.key), str(msg.value));
505
- return { ok: true };
506
- }
507
- case "kv.delete": {
508
- const { store, part } = storeFor("kv", requireKv(msg.ns));
509
- kvStmts(store).del.run(part, str(msg.key));
510
- return { ok: true };
511
- }
512
- case "kv.list": {
513
- const { store, part } = storeFor("kv", requireKv(msg.ns));
514
- return {
515
- ok: true,
516
- keys: kvStmts(store)
517
- .list.all(part, str(msg.prefix))
518
- .map((r) => r.key),
519
- };
520
- }
521
- case "secret.get": {
522
- const name = str(msg.name);
523
- if (!bindings.secrets.includes(name)) throw new Error(`secret not bound: ${name}`);
524
- if (!(name in secrets)) throw new Error(`secret has no value: ${name}`);
525
- return { ok: true, value: secrets[name] };
526
- }
527
- case "fetch":
528
- return proxyFetch(msg);
529
-
530
- case "service.fetch":
531
- return serviceFetch(msg);
532
-
533
- case "d1.query": {
534
- const conn = d1(requireD1(msg.db));
535
- return { ok: true, ...d1Run(conn, str(msg.sql), sqlParams(msg.params)) };
536
- }
537
- case "d1.batch": {
538
- const conn = d1(requireD1(msg.db));
539
- const stmts = Array.isArray(msg.statements) ? msg.statements.map((s) => jsonObject(s) ?? {}) : [];
540
- const runAll = conn.transaction(() => stmts.map((s) => d1Run(conn, str(s.sql), sqlParams(s.params))));
541
- return { ok: true, results: runAll() };
542
- }
543
- case "d1.exec": {
544
- const conn = d1(requireD1(msg.db));
545
- conn.exec(str(msg.sql));
546
- return { ok: true };
547
- }
548
-
549
- case "r2.put": {
550
- const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
551
- const body = str(msg.body);
552
- const etag = createHash("sha256").update(body).digest("hex");
553
- store
554
- .query(
555
- "INSERT INTO r2 (bucket, key, body, size, etag, uploaded, http_json, custom_json) VALUES (?1,?2,?3,?4,?5,?6,?7,?8) " +
556
- "ON CONFLICT (bucket, key) DO UPDATE SET body=?3, size=?4, etag=?5, uploaded=?6, http_json=?7, custom_json=?8",
557
- )
558
- .run(
559
- bucket,
560
- str(msg.key),
561
- body,
562
- Buffer.byteLength(body),
563
- etag,
564
- new Date().toISOString(),
565
- JSON.stringify(msg.httpMetadata ?? {}),
566
- JSON.stringify(msg.customMetadata ?? {}),
567
- );
568
- return {
569
- ok: true,
570
- object: { key: str(msg.key), size: Buffer.byteLength(body), etag, uploaded: new Date().toISOString() },
571
- };
572
- }
573
- case "r2.get":
574
- case "r2.head": {
575
- const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
576
- const row = store
577
- .query<R2Row, [string, string]>("SELECT * FROM r2 WHERE bucket = ? AND key = ?")
578
- .get(bucket, str(msg.key));
579
- if (!row) return { ok: true, found: false };
580
- return { ok: true, found: true, object: r2Row(row), body: msg.op === "r2.get" ? r2Text(row.body) : undefined };
581
- }
582
- case "r2.delete": {
583
- const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
584
- store.query("DELETE FROM r2 WHERE bucket = ? AND key = ?").run(bucket, str(msg.key));
585
- return { ok: true };
586
- }
587
- case "r2.list": {
588
- const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
589
- const prefix = str(msg.prefix);
590
- const cursor = str(msg.cursor);
591
- const limit = Math.min(Math.max(Number(msg.limit) || 1000, 1), 1000);
592
- const rows = store
593
- .query<R2Row, [string, string, string, number]>(
594
- "SELECT * FROM r2 WHERE bucket = ? AND key LIKE ? || '%' AND key > ? ORDER BY key LIMIT ?",
595
- )
596
- .all(bucket, prefix, cursor, limit + 1);
597
- const truncated = rows.length > limit;
598
- const page = truncated ? rows.slice(0, limit) : rows;
599
- return {
600
- ok: true,
601
- objects: page.map(r2Row),
602
- truncated,
603
- cursor: truncated ? page[page.length - 1].key : null,
604
- };
605
- }
606
-
607
- case "queue.send": {
608
- const { store, part: q } = storeFor("queue", requireQueue(msg.queue));
609
- const at = Date.now() + Math.max(0, Number(msg.delaySeconds) || 0) * 1000;
610
- store
611
- .query("INSERT INTO mq (queue, id, body, visible_at) VALUES (?, ?, ?, ?)")
612
- .run(q, newId(), str(msg.body), at);
613
- return { ok: true };
614
- }
615
- case "queue.send_batch": {
616
- const { store, part: q } = storeFor("queue", requireQueue(msg.queue));
617
- const msgs = Array.isArray(msg.messages) ? msg.messages.map((m) => jsonObject(m) ?? {}) : [];
618
- const ins = store.query("INSERT INTO mq (queue, id, body, visible_at) VALUES (?, ?, ?, ?)");
619
- store.transaction(() => {
620
- for (const m of msgs)
621
- ins.run(q, newId(), str(m.body), Date.now() + Math.max(0, Number(m.delaySeconds) || 0) * 1000);
622
- })();
623
- return { ok: true, count: msgs.length };
624
- }
625
-
626
- case "ae.write": {
627
- const ds = requireAe(msg.dataset);
628
- db.query("INSERT INTO ae (dataset, ts, indexes_json, blobs_json, doubles_json) VALUES (?, ?, ?, ?, ?)").run(
629
- ds,
630
- Date.now(),
631
- JSON.stringify(msg.indexes ?? []),
632
- JSON.stringify(msg.blobs ?? []),
633
- JSON.stringify(msg.doubles ?? []),
634
- );
635
- return { ok: true };
636
- }
637
- case "ae.query": {
638
- // Sproutboat extension: Cloudflare AE is write-only from a Worker (you
639
- // query it via the SQL API). Exposed here so a dashboard can read back.
640
- const ds = requireAe(msg.dataset);
641
- const limit = Math.min(Math.max(Number(msg.limit) || 20, 1), 200);
642
- const rows = db
643
- .query<{ ts: number; indexes_json: string; blobs_json: string; doubles_json: string }, [string, number]>(
644
- "SELECT ts, indexes_json, blobs_json, doubles_json FROM ae WHERE dataset = ? ORDER BY ts DESC, rowid DESC LIMIT ?",
645
- )
646
- .all(ds, limit);
647
- const total = db.query<{ n: number }, [string]>("SELECT count(*) AS n FROM ae WHERE dataset = ?").get(ds);
648
- return {
649
- ok: true,
650
- count: total?.n ?? 0,
651
- rows: rows.map((r) => ({
652
- timestamp: r.ts,
653
- indexes: parseJsonValue(r.indexes_json),
654
- blobs: parseJsonValue(r.blobs_json),
655
- doubles: parseJsonValue(r.doubles_json),
656
- })),
657
- };
658
- }
659
-
660
- case "do.storage.get": {
661
- const cls = requireDoClass(msg.cls);
662
- const row = db
663
- .query<{ value: string }, [string, string, string]>(
664
- "SELECT value FROM do_storage WHERE cls = ? AND id = ? AND key = ?",
665
- )
666
- .get(cls, str(msg.id), str(msg.key));
667
- return row ? { ok: true, found: true, value: row.value } : { ok: true, found: false };
668
- }
669
- case "do.storage.put":
670
- db.query(
671
- "INSERT INTO do_storage (cls, id, key, value) VALUES (?1,?2,?3,?4) " +
672
- "ON CONFLICT (cls, id, key) DO UPDATE SET value = ?4",
673
- ).run(requireDoClass(msg.cls), str(msg.id), str(msg.key), str(msg.value));
674
- return { ok: true };
675
- case "do.storage.delete": {
676
- const r = db
677
- .query("DELETE FROM do_storage WHERE cls = ? AND id = ? AND key = ?")
678
- .run(requireDoClass(msg.cls), str(msg.id), str(msg.key));
679
- return { ok: true, deleted: r.changes > 0 };
680
- }
681
- case "do.storage.delete_all":
682
- db.query("DELETE FROM do_storage WHERE cls = ? AND id = ?").run(requireDoClass(msg.cls), str(msg.id));
683
- return { ok: true };
684
- case "do.storage.list": {
685
- const cls = requireDoClass(msg.cls);
686
- const limit = Math.min(Math.max(Number(msg.limit) || 1000, 1), 10000);
687
- const rows = db
688
- .query<{ key: string; value: string }, [string, string, string, number]>(
689
- "SELECT key, value FROM do_storage WHERE cls = ? AND id = ? AND key LIKE ? || '%' ORDER BY key LIMIT ?",
690
- )
691
- .all(cls, str(msg.id), str(msg.prefix), limit);
692
- return { ok: true, entries: rows.map((r) => [r.key, r.value]) };
693
- }
694
-
695
- // #125 — alarms. `at` is epoch ms; setting one replaces any pending alarm
696
- // for that object, and attempts resets because this is a fresh schedule.
697
- case "do.alarm.set":
698
- db.query(
699
- "INSERT INTO do_alarm (cls, id, at, attempts) VALUES (?1,?2,?3,0) " +
700
- "ON CONFLICT (cls, id) DO UPDATE SET at = ?3, attempts = 0",
701
- ).run(requireDoClass(msg.cls), str(msg.id), Math.trunc(Number(msg.at) || 0));
702
- return { ok: true };
703
- case "do.alarm.get": {
704
- const row = db
705
- .query<{ at: number }, [string, string]>("SELECT at FROM do_alarm WHERE cls = ? AND id = ?")
706
- .get(requireDoClass(msg.cls), str(msg.id));
707
- return { ok: true, at: row ? row.at : null };
708
- }
709
- case "do.alarm.delete": {
710
- const r = db.query("DELETE FROM do_alarm WHERE cls = ? AND id = ?").run(requireDoClass(msg.cls), str(msg.id));
711
- return { ok: true, deleted: r.changes > 0 };
712
- }
713
-
714
- case "assets.get": {
715
- if (!bindings.assets) throw new Error("assets not bound");
716
- const reqPath = str(msg.path) || "/";
717
- const key = resolveAssetKey(reqPath, (k) => !!assetManifest?.files[k]);
718
- const hit = key ? readAsset(key) : null;
719
- if (hit)
720
- return {
721
- ok: true,
722
- found: true,
723
- status: 200,
724
- type: hit.type,
725
- hash: hit.hash,
726
- body: new TextDecoder().decode(hit.body),
727
- };
728
- const nfh = assetManifest?.notFound ?? "none";
729
- if (nfh === "single-page-application") {
730
- const shell = readAsset("/index.html");
731
- if (shell)
732
- return {
733
- ok: true,
734
- found: true,
735
- status: 200,
736
- type: shell.type,
737
- hash: shell.hash,
738
- body: new TextDecoder().decode(shell.body),
739
- };
740
- }
741
- if (nfh === "404-page") {
742
- const page = readAsset("/404.html");
743
- if (page)
744
- return {
745
- ok: true,
746
- found: false,
747
- status: 404,
748
- type: page.type,
749
- body: new TextDecoder().decode(page.body),
750
- };
751
- }
752
- return { ok: true, found: false, status: 404, body: "Not Found" };
753
- }
754
-
755
- default:
756
- throw new Error(`unknown op: ${str(msg.op)}`);
757
- }
758
- }
759
-
760
- /**
761
- * #63 — the ops that carry bytes rather than escaping them into the frame.
762
- * Everything else goes through `dispatch` untouched.
763
- */
764
- async function dispatchBinary(msg: Frame, binary: Uint8Array): Promise<{ reply: Frame; bytes?: Uint8Array }> {
765
- if (msg.op === "r2.put") {
766
- // The body arrived as bytes; store it as a blob rather than a string, so
767
- // a put costs one copy instead of an escape, a parse and a re-encode.
768
- const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
769
- const etag = createHash("sha256").update(binary).digest("hex");
770
- const uploaded = new Date().toISOString();
771
- store
772
- .query(
773
- "INSERT INTO r2 (bucket, key, body, size, etag, uploaded, http_json, custom_json) VALUES (?1,?2,?3,?4,?5,?6,?7,?8) " +
774
- "ON CONFLICT (bucket, key) DO UPDATE SET body=?3, size=?4, etag=?5, uploaded=?6, http_json=?7, custom_json=?8",
775
- )
776
- .run(
777
- bucket,
778
- str(msg.key),
779
- binary,
780
- binary.byteLength,
781
- etag,
782
- uploaded,
783
- JSON.stringify(msg.httpMetadata ?? {}),
784
- JSON.stringify(msg.customMetadata ?? {}),
785
- );
786
- return {
787
- reply: { ok: true, object: { key: str(msg.key), size: binary.byteLength, etag, uploaded } },
788
- };
789
- }
790
-
791
- if (msg.op === "r2.get") {
792
- const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
793
- const row = store
794
- .query<R2Row, [string, string]>("SELECT * FROM r2 WHERE bucket = ? AND key = ?")
795
- .get(bucket, str(msg.key));
796
- if (!row) return { reply: { ok: true, found: false } };
797
- return { reply: { ok: true, found: true, object: r2Row(row) }, bytes: r2Bytes(row.body) };
798
- }
799
-
800
- if (msg.op === "assets.get") {
801
- if (!bindings.assets) throw new Error("assets not bound");
802
- const reqPath = str(msg.path) || "/";
803
- const key = resolveAssetKey(reqPath, (candidate) => !!assetManifest?.files[candidate]);
804
- let hit = key ? readAsset(key) : null;
805
- let found = Boolean(hit);
806
- let status = hit ? 200 : 404;
807
- if (!hit && assetManifest?.notFound === "single-page-application") {
808
- hit = readAsset("/index.html");
809
- found = Boolean(hit);
810
- status = hit ? 200 : 404;
811
- } else if (!hit && assetManifest?.notFound === "404-page") {
812
- hit = readAsset("/404.html");
813
- }
814
- if (!hit) return { reply: { ok: true, found: false, status: 404 }, bytes: new TextEncoder().encode("Not Found") };
815
- return {
816
- reply: { ok: true, found, status, type: hit.type, hash: hit.hash },
817
- bytes: hit.body,
818
- };
819
- }
820
-
821
- return { reply: await dispatchOnce(msg) };
822
- }
823
-
824
- /** One wire frame in, one out. See `handleFrame` on the Broker type. */
825
- async function handleFrame(payload: Buffer): Promise<Buffer> {
826
- if (payload.length === 0 || payload[0] !== FRAME_V1) {
827
- return encodeFrame(await handlePayload(payload.toString("utf8")));
828
- }
829
- try {
830
- const jsonLen = payload.readUInt32LE(1);
831
- const msg = jsonObject(parseJsonValue(payload.subarray(5, 5 + jsonLen).toString("utf8")));
832
- if (!msg) throw new Error("request frame was not a JSON object");
833
- if (token && str(msg.token) !== token) return frameOf(encodeV1({ ok: false, error: "unauthorized" }));
834
- const binary = new Uint8Array(payload.subarray(5 + jsonLen));
835
- const { reply, bytes } = await dispatchBinary(msg, binary);
836
- return frameOf(encodeV1(reply, bytes));
837
- } catch (e) {
838
- return frameOf(encodeV1({ ok: false, error: e instanceof Error ? e.message : String(e) }));
839
- }
840
- }
841
-
842
- /**
843
- * #63 §3 — ops that must not be applied twice.
844
- *
845
- * The transport retries the same bytes when a connection dies, and a broker
846
- * that already applied a request but never got its reply out would otherwise
847
- * apply it again: a second queue message, a second INSERT. Reads are absent
848
- * deliberately — replaying a `d1.query` costs nothing and caching its reply
849
- * could hold megabytes.
850
- */
851
- const REPLAYABLE = new Set([
852
- "kv.put",
853
- "kv.delete",
854
- "queue.send",
855
- "queue.send_batch",
856
- "d1.query",
857
- "d1.exec",
858
- "d1.batch",
859
- "r2.put",
860
- "r2.delete",
861
- "do.storage.put",
862
- "do.storage.delete",
863
- "do.storage.delete_all",
864
- "do.alarm.set",
865
- "do.alarm.delete",
866
- "ae.write",
867
- ]);
868
-
869
- /** How many recent replies to keep. A resend follows its original within
870
- * milliseconds, so this only has to outlive a reconnect, not a session. */
871
- const REPLAY_WINDOW = 256;
872
-
873
- /**
874
- * Run `msg`, or replay what it answered last time. Keyed by the id the sprout
875
- * put on the request; a request without one is always run.
876
- */
877
- async function dispatchOnce(msg: Frame): Promise<Frame> {
878
- const id = isSafeInteger(msg.id) ? msg.id : null;
879
- const op = str(msg.op);
880
- if (id === null || !REPLAYABLE.has(op)) return dispatch(msg);
881
- const key = `${op}:${id}`;
882
- const seen = replayed.get(key);
883
- if (seen) return seen;
884
- const reply = await dispatch(msg);
885
- replayed.set(key, reply);
886
- // Insertion-ordered, so the oldest key is the first one out.
887
- if (replayed.size > REPLAY_WINDOW) {
888
- const oldest = replayed.keys().next().value;
889
- if (oldest !== undefined) replayed.delete(oldest);
890
- }
891
- return reply;
892
- }
893
-
894
- async function handlePayload(payload: string): Promise<Frame> {
895
- const nl = payload.indexOf("\n");
896
- const gotToken = nl === -1 ? "" : payload.slice(0, nl);
897
- const json = nl === -1 ? payload : payload.slice(nl + 1);
898
- if (token && gotToken !== token) return { ok: false, error: "unauthorized" };
899
- try {
900
- const msg = jsonObject(parseJsonValue(json));
901
- if (!msg) throw new Error("request frame was not a JSON object");
902
- return await dispatchOnce(msg);
903
- } catch (e) {
904
- return { ok: false, error: e instanceof Error ? e.message : String(e) };
905
- }
906
- }
907
-
908
- // --- cron + queue delivery (only when this broker knows its sprout) --------
909
- const timers: ReturnType<typeof setInterval>[] = [];
910
- const QUEUE_BATCH = 10;
911
- const QUEUE_MAX_ATTEMPTS = 5;
912
-
913
- async function deliverTrigger(kind: "scheduled" | "queue" | "alarm", body: JsonObject): Promise<Response | null> {
914
- if (!opts.sproutUrl) return null;
915
- try {
916
- return await doFetch(opts.sproutUrl, {
917
- method: "POST",
918
- headers: { "x-sb-trigger": kind, "x-sb-token": token, "content-type": "application/json" },
919
- body: JSON.stringify(body),
920
- });
921
- } catch {
922
- return null;
923
- }
924
- }
925
-
926
- function drainQueuesOnce(): void {
927
- if (!opts.sproutUrl || bindings.queues.length === 0) return;
928
- const now = Date.now();
929
- for (const binding of bindings.queues) {
930
- // `store`/`part` route the rows to this queue's backing file (#74); the
931
- // trigger payload still carries the binding name the sprout matches on.
932
- const { store, part } = storeFor("queue", binding);
933
- const rows = store
934
- .query<{ id: string; body: string; attempts: number }, [string, number, number]>(
935
- "SELECT id, body, attempts FROM mq WHERE queue = ? AND dead = 0 AND visible_at <= ? ORDER BY visible_at LIMIT ?",
936
- )
937
- .all(part, now, QUEUE_BATCH);
938
- if (rows.length === 0) continue;
939
- // hide the batch so the next tick doesn't re-deliver it while in flight
940
- const hideUntil = now + 30_000;
941
- const hide = store.query("UPDATE mq SET visible_at = ? WHERE id = ?");
942
- for (const r of rows) hide.run(hideUntil, r.id);
943
-
944
- void deliverTrigger("queue", {
945
- queue: binding,
946
- messages: rows.map((r) => ({ id: r.id, body: r.body, timestamp: now, attempts: r.attempts + 1 })),
947
- }).then(async (res) => {
948
- let ack: string[] = rows.map((r) => r.id); // default: ack all if the sprout did not say
949
- let retry: string[] = [];
950
- if (res && res.ok) {
951
- try {
952
- const parsed = jsonObject(parseJsonValue(await res.text()));
953
- const ids = (value: JsonValue | undefined) => (Array.isArray(value) ? value.filter(isString) : null);
954
- ack = (parsed && ids(parsed.ack)) ?? ack;
955
- retry = (parsed && ids(parsed.retry)) ?? [];
956
- } catch {
957
- /* keep defaults */
958
- }
959
- } else {
960
- ack = [];
961
- retry = rows.map((r) => r.id); // delivery failed → retry all
962
- }
963
- const del = store.query("DELETE FROM mq WHERE id = ?");
964
- for (const id of ack) del.run(id);
965
- const bump = store.query(
966
- "UPDATE mq SET attempts = attempts + 1, visible_at = ?, dead = CASE WHEN attempts + 1 >= ? THEN 1 ELSE 0 END WHERE id = ?",
967
- );
968
- for (const id of retry) bump.run(Date.now() + 5_000, QUEUE_MAX_ATTEMPTS, id);
969
- });
970
- }
971
- }
972
-
973
- const ALARM_MAX_ATTEMPTS = 5;
974
-
975
- /**
976
- * #125 — fire alarms that are due.
977
- *
978
- * Claim-then-deliver, not deliver-then-delete: `alarm()` is allowed to call
979
- * `setAlarm()` for its next run, and that write lands *during* delivery. If
980
- * we deleted afterwards we would erase the alarm the handler just scheduled,
981
- * which is how a self-rescheduling object silently stops. Deleting first
982
- * means the handler's row is a fresh insert nothing else touches.
983
- *
984
- * A failed delivery re-arms with the queue consumer's policy — 5s apart, five
985
- * attempts — and `DO NOTHING` so a re-arm never overwrites an alarm the
986
- * handler set itself.
987
- */
988
- function fireAlarmsOnce(): void {
989
- if (!opts.sproutUrl || bindings.do.length === 0) return;
990
- const now = Date.now();
991
- const due = db
992
- .query<{ cls: string; id: string; at: number; attempts: number }, [number]>(
993
- "SELECT cls, id, at, attempts FROM do_alarm WHERE at <= ? ORDER BY at LIMIT 10",
994
- )
995
- .all(now);
996
- if (due.length === 0) return;
997
- const claim = db.query("DELETE FROM do_alarm WHERE cls = ? AND id = ?");
998
- const rearm = db.query(
999
- "INSERT INTO do_alarm (cls, id, at, attempts) VALUES (?1,?2,?3,?4) ON CONFLICT (cls, id) DO NOTHING",
1000
- );
1001
- for (const row of due) {
1002
- claim.run(row.cls, row.id);
1003
- void deliverTrigger("alarm", { cls: row.cls, id: row.id, scheduledTime: row.at }).then((res) => {
1004
- if (res && res.ok) return;
1005
- const attempts = row.attempts + 1;
1006
- if (attempts >= ALARM_MAX_ATTEMPTS) return; // give up rather than spin forever
1007
- rearm.run(row.cls, row.id, Date.now() + 5_000, attempts);
1008
- });
1009
- }
1010
- }
1011
-
1012
- if (opts.sproutUrl) {
1013
- if (bindings.queues.length > 0) timers.push(setInterval(drainQueuesOnce, 500));
1014
- if (bindings.do.length > 0) timers.push(setInterval(fireAlarmsOnce, 500));
1015
- if (bindings.crons.length > 0) {
1016
- let lastTick = "";
1017
- timers.push(
1018
- setInterval(() => {
1019
- const now = new Date();
1020
- const stamp = `${now.getUTCFullYear()}-${now.getUTCMonth()}-${now.getUTCDate()}-${now.getUTCHours()}-${now.getUTCMinutes()}`;
1021
- if (stamp === lastTick) return; // once per minute
1022
- lastTick = stamp;
1023
- for (const expr of bindings.crons) {
1024
- if (cronMatches(expr, now)) void deliverTrigger("scheduled", { cron: expr, scheduledTime: now.getTime() });
1025
- }
1026
- }, 15_000),
1027
- );
1028
- }
1029
- }
1030
-
1031
- return {
1032
- dispatch,
1033
- handlePayload,
1034
- handleFrame,
1035
- close: () => {
1036
- for (const t of timers) clearInterval(t);
1037
- for (const conn of d1Conns.values()) conn.close();
1038
- for (const conn of resourceDbs.values()) conn.close();
1039
- db.close();
1040
- },
1041
- };
1042
- }
1043
-
1044
- /**
1045
- * Minimal 5-field cron match (`min hour dom month dow`, UTC). Each field is a
1046
- * comma list of: `*`, a step `*` + `/n`, a range `a-b`, or a plain number.
1047
- */
1048
- export function cronMatches(expr: string, when: Date): boolean {
1049
- const parts = expr.trim().split(/\s+/);
1050
- if (parts.length !== 5) return false;
1051
- const fields = [
1052
- when.getUTCMinutes(),
1053
- when.getUTCHours(),
1054
- when.getUTCDate(),
1055
- when.getUTCMonth() + 1,
1056
- when.getUTCDay(),
1057
- ];
1058
- const inField = (spec: string, value: number): boolean =>
1059
- spec.split(",").some((token) => {
1060
- if (token === "*") return true;
1061
- const step = token.startsWith("*/") ? Number(token.slice(2)) : null;
1062
- if (step) return value % step === 0;
1063
- const range = token.split("-");
1064
- if (range.length === 2) return value >= Number(range[0]) && value <= Number(range[1]);
1065
- return Number(token) === value;
1066
- });
1067
- return parts.every((spec, i) => inField(spec, fields[i]));
1068
- }
1069
-
1070
- export function encodeFrame(obj: Frame): Buffer {
1071
- const body = Buffer.from(JSON.stringify(obj), "utf8");
1072
- const frame = Buffer.allocUnsafe(4 + body.length);
1073
- frame.writeUInt32LE(body.length, 0);
1074
- body.copy(frame, 4);
1075
- return frame;
1076
- }
1077
-
1078
- /** The bound TCP listener: the port actually assigned, and its shutdown handle. */
1079
- export type BrokerServer = { port: number; stop(): void };
1080
-
1081
- /** Start the TCP listener. Returns the bound port. */
1082
- export function listen(broker: Broker, hostname: string, port: number): BrokerServer {
1083
- // Chunks are held in a list and joined once, when a whole frame has arrived.
1084
- // Concatenating on every chunk copies the accumulated buffer each time, which
1085
- // is quadratic in the body size: an 8 MB object arriving in 64 KB pieces
1086
- // copied hundreds of megabytes before anything was parsed.
1087
- const server = Bun.listen<{ chunks: Buffer[]; size: number }>({
1088
- hostname,
1089
- port,
1090
- socket: {
1091
- open(socket) {
1092
- socket.data = { chunks: [], size: 0 };
1093
- },
1094
- async data(socket, chunk) {
1095
- const state = socket.data;
1096
- state.chunks.push(chunk);
1097
- state.size += chunk.length;
1098
- for (;;) {
1099
- if (state.size < 4) return;
1100
- if (state.chunks.length > 1) {
1101
- state.chunks = [Buffer.concat(state.chunks, state.size)];
1102
- }
1103
- const buf = state.chunks[0];
1104
- const len = buf.readUInt32LE(0);
1105
- if (len > MAX_FRAME) {
1106
- socket.write(encodeFrame({ ok: false, error: "frame too large" }));
1107
- socket.end();
1108
- return;
1109
- }
1110
- if (state.size < 4 + len) return;
1111
- // Kept as bytes: a v1 frame carries binary after its JSON, and
1112
- // decoding the whole payload as utf8 would corrupt it.
1113
- const payload = buf.subarray(4, 4 + len);
1114
- const rest = buf.subarray(4 + len);
1115
- state.chunks = rest.length ? [Buffer.from(rest)] : [];
1116
- state.size = rest.length;
1117
- socket.write(await broker.handleFrame(payload));
1118
- }
1119
- },
1120
- },
1121
- });
1122
- return { port: server.port, stop: () => server.stop(true) };
1123
- }
1124
-
1125
- if (import.meta.main) {
1126
- const { values } = parseArgs({
1127
- options: {
1128
- port: { type: "string" },
1129
- token: { type: "string" },
1130
- db: { type: "string" },
1131
- "data-dir": { type: "string" },
1132
- "resource-dir": { type: "string" },
1133
- bindings: { type: "string" },
1134
- secrets: { type: "string" },
1135
- "sprout-url": { type: "string" },
1136
- "assets-dir": { type: "string" },
1137
- "edge-url": { type: "string" },
1138
- services: { type: "string" },
1139
- },
1140
- });
1141
- // SAFETY: --bindings and --secrets are the artifact's own bindings.json /
1142
- // secrets.json, written by `sproutboat build` and handed to us by the supervisor.
1143
- const bindings: Partial<Bindings> | undefined = values.bindings
1144
- ? (JSON.parse(readFileSync(values.bindings, "utf8")) as Partial<Bindings>)
1145
- : undefined;
1146
- // SAFETY: as above — secrets.json is a flat name->value map written by the build.
1147
- const secrets: Record<string, string> | undefined = values.secrets
1148
- ? (JSON.parse(readFileSync(values.secrets, "utf8")) as Record<string, string>)
1149
- : undefined;
1150
- // #48 — binding -> hostname, resolved by the control plane at activation and
1151
- // passed as JSON. Malformed input disables service calls rather than taking
1152
- // the broker down: every other binding still works.
1153
- let services: Record<string, string> | undefined;
1154
- if (values.services) {
1155
- try {
1156
- const parsed = jsonObject(parseJsonValue(values.services));
1157
- if (parsed) {
1158
- services = Object.fromEntries(
1159
- Object.entries(parsed).flatMap(([binding, host]) => (isString(host) ? [[binding, host] as const] : [])),
1160
- );
1161
- }
1162
- } catch {
1163
- console.error("sproutboat broker: --services is not valid JSON; service bindings disabled");
1164
- }
1165
- }
1166
- const broker = createBroker({
1167
- db: values.db,
1168
- dataDir: values["data-dir"],
1169
- resourceDir: values["resource-dir"],
1170
- token: values.token ?? process.env.SB_BROKER_TOKEN,
1171
- bindings,
1172
- secrets,
1173
- sproutUrl: values["sprout-url"] ?? process.env.SB_SPROUT_URL,
1174
- assetsDir: values["assets-dir"],
1175
- edgeUrl: values["edge-url"],
1176
- services,
1177
- });
1178
- const { port } = listen(broker, "127.0.0.1", Number(values.port ?? process.env.SB_BROKER_PORT ?? 0));
1179
- console.log(
1180
- `sproutboat broker: 127.0.0.1:${port} db=${values.db ?? ":memory:"} sprout=${values["sprout-url"] ?? process.env.SB_SPROUT_URL ?? "(none)"}`,
1181
- );
1182
- }
1
+ // Re-exported from @sproutboat/wire (moved verbatim). This shim keeps
2
+ // every `./broker` importer and the `sproutboat/runtime/broker` export path
3
+ // working; the implementation lives in the shared package.
4
+ export * from "@sproutboat/wire";