@sproutboat/wire 0.3.0 → 0.5.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,33 @@
1
1
  # @sproutboat/wire
2
2
 
3
+ ## 0.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Rate limiter (baronunread/sproutboat#69): `env.<NAME>.limit({ key })` now
8
+ returns `{ success, resetAt }`. `resetAt` is epoch milliseconds for when the
9
+ fixed window rolls and the counter clears, returned on every call (hit or
10
+ miss), so a rejection can carry an accurate `Retry-After`:
11
+ `Math.ceil((resetAt - Date.now()) / 1000)`. The `ratelimit.check` op already
12
+ had `windowStart` and `period` in hand; it just stopped discarding them.
13
+ Additive: existing callers reading only `success` are unaffected.
14
+
15
+ ## 0.4.0
16
+
17
+ ### Minor Changes
18
+
19
+ - Rate Limiting binding (baronunread/sproutboat#69): `env.<NAME>.limit({ key }) ->
20
+ { success }`.
21
+
22
+ - Config: `ratelimiters: [{ binding, limit, period }]` in `sproutboat.jsonc` —
23
+ at most `limit` calls per `period` seconds, per key.
24
+ - A fixed-window counter (`ratelimit` table) on **both** transports: the
25
+ embedded one for standalone binaries, and the broker (`ratelimit.check` op,
26
+ in the replay-dedup set) for deployed sprouts. `limit` / `period` travel in
27
+ the message so the op stays stateless.
28
+ - `ponytail`: fixed window, so a burst across the boundary can briefly reach
29
+ ~2x; swap for a two-window weighted count if that matters.
30
+
3
31
  ## 0.3.0
4
32
 
5
33
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sproutboat/wire",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -737,3 +737,22 @@ 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
+ const over = await hit("a"); // 4 — over
749
+ expect(over.success).toBe(false);
750
+ // resetAt is epoch ms at the window edge, so it drives a Retry-After.
751
+ expect(over.resetAt).toBeGreaterThan(Date.now());
752
+ expect(over.resetAt).toBeLessThanOrEqual(Date.now() + 60_000);
753
+ expect((await hit("b")).success).toBe(true); // a different key has its own count
754
+
755
+ await expect(b.dispatch({ op: "ratelimit.check", name: "NOPE", key: "x", limit: 1, period: 60 })).rejects.toThrow(
756
+ "not bound",
757
+ );
758
+ });
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, resetAt: (windowStart + period) * 1000 };
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",