@sproutboat/wire 0.3.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 +16 -0
- package/package.json +1 -1
- package/src/broker.test.ts +15 -0
- package/src/broker.ts +30 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
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
|
+
|
|
3
19
|
## 0.3.0
|
|
4
20
|
|
|
5
21
|
### Minor Changes
|
package/package.json
CHANGED
package/src/broker.test.ts
CHANGED
|
@@ -737,3 +737,18 @@ test("replaying a read is allowed, since it changes nothing", async () => {
|
|
|
737
737
|
const c = await read();
|
|
738
738
|
expect(a.subarray(4).toString()).toBe(c.subarray(4).toString());
|
|
739
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
|
@@ -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);
|
|
@@ -586,6 +597,24 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
586
597
|
return { ok: true, ...d1Backup(d1(name), name, msg.name == null ? "" : str(msg.name)) };
|
|
587
598
|
}
|
|
588
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
|
+
}
|
|
617
|
+
|
|
589
618
|
case "r2.put": {
|
|
590
619
|
const { store, part: bucket } = storeFor("r2", requireR2(msg.bucket));
|
|
591
620
|
const body = str(msg.body);
|
|
@@ -897,6 +926,7 @@ export function createBroker(opts: BrokerOptions = {}): Broker {
|
|
|
897
926
|
"d1.exec",
|
|
898
927
|
"d1.batch",
|
|
899
928
|
"d1.backup",
|
|
929
|
+
"ratelimit.check",
|
|
900
930
|
"r2.put",
|
|
901
931
|
"r2.delete",
|
|
902
932
|
"do.storage.put",
|