@sproutboat/wire 0.2.0 → 0.4.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/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # @sproutboat/wire
2
2
 
3
+ ## 0.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Rate Limiting binding (baronunread/sproutboat#69): `env.<NAME>.limit({ key }) ->
8
+ { success }`.
9
+
10
+ - Config: `ratelimiters: [{ binding, limit, period }]` in `sproutboat.jsonc` —
11
+ at most `limit` calls per `period` seconds, per key.
12
+ - A fixed-window counter (`ratelimit` table) on **both** transports: the
13
+ embedded one for standalone binaries, and the broker (`ratelimit.check` op,
14
+ in the replay-dedup set) for deployed sprouts. `limit` / `period` travel in
15
+ the message so the op stays stateless.
16
+ - `ponytail`: fixed window, so a burst across the boundary can briefly reach
17
+ ~2x; swap for a two-window weighted count if that matters.
18
+
19
+ ## 0.3.0
20
+
21
+ ### Minor Changes
22
+
23
+ - Standalone runtime: client IP, D1 online backup, and a prepared-statement cache.
24
+
25
+ - `request.cf.clientIp` is populated from the connection's remote address
26
+ (`x-sb-remote-addr`, appended by the server and stripped from client input),
27
+ with `SB_TRUSTED_PROXIES` resolving `X-Forwarded-For` behind a reverse proxy.
28
+ IPv4-mapped IPv6 peers are folded to dotted form. (baronunread/sproutboat#163)
29
+ - `env.<D1>.backup(name?)` — a Sproutboat extension: an online, integrity-checked
30
+ single-file snapshot via `VACUUM INTO`, on both the embedded and broker
31
+ transports. New `d1.backup` op. (baronunread/sproutboat#164)
32
+ - The embedded transport caches prepared statements per database (FIFO, 32/db)
33
+ instead of recompiling the SQL on every binding op. (baronunread/sproutboat#155)
34
+
3
35
  ## 0.2.0
4
36
 
5
37
  ### Minor Changes
package/README.md CHANGED
@@ -1,15 +1,30 @@
1
1
  # `@sproutboat/wire`
2
2
 
3
- Single source of truth for the broker frame protocol: op dispatch, storage
4
- tables, queue and alarm delivery, plus the JSON validation utilities the
5
- protocol is built on.
6
-
7
- Current sources, moved verbatim: `sproutboat-cli/src/broker.ts`,
8
- `broker.test.ts`, `json.ts`. Contract tests move with the code; the CLI's
9
- `CONTRACTS.md` broker-wire and storage sections keep generating from here.
10
-
11
- Note: `broker.ts` imports `@sproutboat/assets`, deliberately declared
12
- nowhere here. Bun resolves transitive `file:` links from the consumer root,
13
- not the declaring package, so every consumer links all `@sproutboat/*`
14
- packages it needs at its own root. Add a real version range here at first
15
- npm publish.
3
+ Broker frame protocol and the JSON validation utilities it is built on.
4
+ Single source of truth shared by the CLI dev broker and the platform's
5
+ edge and supervisor.
6
+
7
+ ```sh
8
+ bun add @sproutboat/wire
9
+ ```
10
+
11
+ ```ts
12
+ import { createBroker } from "@sproutboat/wire";
13
+ import { parseJsonValue } from "@sproutboat/wire";
14
+ ```
15
+
16
+ ## API
17
+
18
+ - `createBroker(options)` opens a binding broker over a resource file:
19
+ KV, D1, R2, queues, Durable Object storage and alarms, Analytics Engine
20
+ points, secrets, outbound fetch. Frame envelope, both directions:
21
+ `[u32 LE length][payload]`.
22
+ - Frame helpers: `frameOf`, `encodeFrame`, `encodeV1`, `FRAME_V1`,
23
+ `cronMatches`, plus the `Broker`, `BrokerOptions`, `BrokerServer`,
24
+ `FetchLike` and `Frame` types.
25
+ - JSON boundary parsing: `parseJsonValue`, `jsonObject`, guards
26
+ (`isString`, `isBoolean`, `isSafeInteger`), `JsonValue` and `JsonObject`
27
+ types. Parse at the I/O boundary, then narrow; nothing downstream takes
28
+ an unparsed value.
29
+
30
+ See `src/broker.test.ts` for the protocol contract in executable form.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/wire",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -105,6 +105,30 @@ test("D1: query / run / batch on a bound database, isolated per name", async ()
105
105
  await expect(b.dispatch({ op: "d1.query", db: "NOPE", sql: "SELECT 1", params: [] })).rejects.toThrow("not bound");
106
106
  });
107
107
 
108
+ test("D1: backup writes an integrity-checked snapshot, replayed from cache", async () => {
109
+ const dir = mkdtempSync(join(tmpdir(), "sb-broker-backup-"));
110
+ try {
111
+ const b = make({ db: join(dir, "store.sqlite"), bindings: { d1: ["DB"] } });
112
+ await b.dispatch({ op: "d1.exec", db: "DB", sql: "CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)" });
113
+ await b.dispatch({ op: "d1.query", db: "DB", sql: "INSERT INTO t (name) VALUES (?)", params: ["ada"] });
114
+
115
+ const bk = await b.dispatch({ op: "d1.backup", db: "DB", name: "snap.sqlite" });
116
+ expect(bk.ok).toBe(true);
117
+ expect(String(bk.path).endsWith(join("backups", "snap.sqlite"))).toBe(true);
118
+ expect(Number(bk.bytes)).toBeGreaterThan(0);
119
+ expect(existsSync(String(bk.path))).toBe(true);
120
+
121
+ // the snapshot is a real, readable database with the row in it
122
+ const snap = new Database(String(bk.path), { readonly: true });
123
+ expect(snap.query("SELECT name FROM t").all()).toEqual([{ name: "ada" }]);
124
+ snap.close();
125
+
126
+ await expect(b.dispatch({ op: "d1.backup", db: "NOPE" })).rejects.toThrow("not bound");
127
+ } finally {
128
+ rmSync(dir, { recursive: true, force: true });
129
+ }
130
+ });
131
+
108
132
  test("R2: put / get / head / list / delete on a bound bucket", async () => {
109
133
  const b = make({ bindings: { r2: ["ASSETS"] } });
110
134
  const put = await b.dispatch({
@@ -713,3 +737,18 @@ test("replaying a read is allowed, since it changes nothing", async () => {
713
737
  const c = await read();
714
738
  expect(a.subarray(4).toString()).toBe(c.subarray(4).toString());
715
739
  });
740
+
741
+ test("ratelimit: fixed window per key, limit/period from the message (#69)", async () => {
742
+ const b = make({ bindings: { ratelimiters: [{ binding: "API", limit: 3, period: 60 }] } });
743
+ const hit = (key: string) => b.dispatch({ op: "ratelimit.check", name: "API", key, limit: 3, period: 60 });
744
+
745
+ expect((await hit("a")).success).toBe(true); // 1
746
+ expect((await hit("a")).success).toBe(true); // 2
747
+ expect((await hit("a")).success).toBe(true); // 3
748
+ expect((await hit("a")).success).toBe(false); // 4 — over
749
+ expect((await hit("b")).success).toBe(true); // a different key has its own count
750
+
751
+ await expect(b.dispatch({ op: "ratelimit.check", name: "NOPE", key: "x", limit: 1, period: 60 })).rejects.toThrow(
752
+ "not bound",
753
+ );
754
+ });
package/src/broker.ts CHANGED
@@ -19,7 +19,7 @@
19
19
  */
20
20
  import { Database, type Statement } from "bun:sqlite";
21
21
  import { createHash } from "node:crypto";
22
- import { existsSync, mkdirSync, readFileSync } from "node:fs";
22
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync } 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 "@sproutboat/assets";
@@ -38,6 +38,9 @@ export type Bindings = {
38
38
  /** #48 — worker-to-worker: binding name -> the project it calls. */
39
39
  services: Array<{ binding: string; service: string }>;
40
40
  crons: string[];
41
+ /** #69 — rate limiters. Only the names are used here; the message carries
42
+ * `limit` / `period` (both come from the same baked config as the handler). */
43
+ ratelimiters: Array<{ binding: string; limit: number; period: number }>;
41
44
  /** Static-asset binding name, or `""` when assets are edge-only. */
42
45
  assets: string;
43
46
  /**
@@ -181,6 +184,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
181
184
  do: [],
182
185
  services: [],
183
186
  crons: [],
187
+ ratelimiters: [],
184
188
  assets: "",
185
189
  resources: {},
186
190
  ...opts.bindings,
@@ -264,6 +268,11 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
264
268
  );
265
269
  // Alarm polling reads the earliest due objects across classes.
266
270
  db.exec("CREATE INDEX IF NOT EXISTS do_alarm_due ON do_alarm (at)");
271
+ // #69 — fixed-window rate-limiter counters, per binding name + key.
272
+ db.exec(
273
+ "CREATE TABLE IF NOT EXISTS ratelimit (name TEXT NOT NULL, key TEXT NOT NULL, window_start INTEGER NOT NULL, " +
274
+ "count INTEGER NOT NULL, PRIMARY KEY (name, key))",
275
+ );
267
276
 
268
277
  // #74 — one SQLite file per account-level resource id, opened on first use.
269
278
  const resourceDbs = new Map<string, Database>();
@@ -326,6 +335,8 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
326
335
  const requireR2 = bound(bindings.r2, "R2 bucket");
327
336
  const requireQueue = bound(bindings.queues, "queue");
328
337
  const requireAe = bound(bindings.analytics, "analytics dataset");
338
+ const rateLimiterNames = bindings.ratelimiters.map((r) => r.binding);
339
+ const requireRateLimiter = bound(rateLimiterNames, "rate limiter");
329
340
  const doClasses = new Set(bindings.do.map((d) => d.className));
330
341
  const requireDoClass = (cls: JsonValue | undefined): string => {
331
342
  const n = str(cls);
@@ -375,6 +386,34 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
375
386
  };
376
387
  };
377
388
 
389
+ // #164 — an online, integrity-checked snapshot of a D1 database via
390
+ // VACUUM INTO. Standalone (transport-embedded) is the primary target; the
391
+ // broker carries it too so one conformance suite covers both backends.
392
+ const d1Backup = (conn: Database, dbName: string, name: string) => {
393
+ const source = conn.filename;
394
+ if (!source || source === ":memory:") throw new Error("d1 backup needs a file-backed database");
395
+ // A caller-supplied name must be a plain basename; else use the default.
396
+ const file = /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(name)
397
+ ? name
398
+ : `${dbName}-${new Date().toISOString().replace(/[:.]/g, "-")}.sqlite`;
399
+ const dir = join(dirname(dirname(resolve(source))), "backups");
400
+ mkdirSync(dir, { recursive: true });
401
+ const dest = join(dir, file);
402
+ if (existsSync(dest)) rmSync(dest); // VACUUM INTO refuses an existing target
403
+ conn.run("VACUUM INTO ?", [dest]);
404
+ const copy = new Database(dest, { readonly: true });
405
+ try {
406
+ const row = copy.query<{ integrity_check: string }, []>("PRAGMA integrity_check").get();
407
+ if (row?.integrity_check !== "ok") {
408
+ rmSync(dest, { force: true });
409
+ throw new Error("integrity check failed on the backup");
410
+ }
411
+ } finally {
412
+ copy.close();
413
+ }
414
+ return { path: dest, bytes: statSync(dest).size };
415
+ };
416
+
378
417
  /** An object body as bytes, whichever storage class it came back in. */
379
418
  const r2Bytes = (body: string | Uint8Array): Uint8Array =>
380
419
  body instanceof Uint8Array ? body : new TextEncoder().encode(body);
@@ -553,6 +592,28 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
553
592
  conn.exec(str(msg.sql));
554
593
  return { ok: true };
555
594
  }
595
+ case "d1.backup": {
596
+ const name = requireD1(msg.db);
597
+ return { ok: true, ...d1Backup(d1(name), name, msg.name == null ? "" : str(msg.name)) };
598
+ }
599
+
600
+ case "ratelimit.check": {
601
+ // #69 — fixed window; limit/period arrive in the message. Same upsert as
602
+ // the embedded transport so one conformance suite covers both.
603
+ const name = requireRateLimiter(msg.name);
604
+ const limit = Math.max(Number(msg.limit) || 1, 1);
605
+ const period = Math.max(Number(msg.period) || 60, 1);
606
+ const now = Math.floor(Date.now() / 1000);
607
+ const windowStart = now - (now % period);
608
+ const row = db
609
+ .query<{ count: number }, [string, string, number]>(
610
+ "INSERT INTO ratelimit (name, key, window_start, count) VALUES (?1, ?2, ?3, 1) " +
611
+ "ON CONFLICT (name, key) DO UPDATE SET count = CASE WHEN window_start = ?3 THEN count + 1 ELSE 1 END, " +
612
+ "window_start = ?3 RETURNING count",
613
+ )
614
+ .get(name, msg.key == null ? "" : str(msg.key), windowStart);
615
+ return { ok: true, success: (row?.count ?? 1) <= limit };
616
+ }
556
617
 
557
618
  case "r2.put": {
558
619
  const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
@@ -864,6 +925,8 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
864
925
  "d1.query",
865
926
  "d1.exec",
866
927
  "d1.batch",
928
+ "d1.backup",
929
+ "ratelimit.check",
867
930
  "r2.put",
868
931
  "r2.delete",
869
932
  "do.storage.put",