@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 +32 -0
- package/README.md +28 -13
- package/package.json +1 -1
- package/src/broker.test.ts +39 -0
- package/src/broker.ts +64 -1
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
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
package/src/broker.test.ts
CHANGED
|
@@ -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",
|