@freecorps/ember-rate-limit 0.0.0-stage → 0.3.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/LICENSE +21 -0
- package/README.md +11 -2
- package/dist/algorithms.d.ts +54 -0
- package/dist/algorithms.js +88 -0
- package/dist/component.d.ts +275 -0
- package/dist/component.js +143 -0
- package/dist/index.d.ts +138 -0
- package/dist/index.js +87 -0
- package/package.json +46 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FreeCorps
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @freecorps/ember-rate-limit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Rate limiting component for Ember: token bucket and fixed window per key, stored in the shard and transactional inside mutations; optional Cloudflare Rate Limiting binding helper.
|
|
4
|
+
|
|
5
|
+
Part of [Ember](https://github.com/freecorps/ember), a Convex-style backend on Cloudflare Workers and Durable
|
|
6
|
+
Objects. Source: [`packages/rate-limit`](https://github.com/freecorps/ember/tree/main/packages/rate-limit).
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm install @freecorps/ember-rate-limit
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
License: MIT.
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export type TokenBucket = {
|
|
2
|
+
kind: "token bucket";
|
|
3
|
+
rate: number;
|
|
4
|
+
period: number;
|
|
5
|
+
capacity?: number;
|
|
6
|
+
maxReserved?: number;
|
|
7
|
+
};
|
|
8
|
+
export type FixedWindow = {
|
|
9
|
+
kind: "fixed window";
|
|
10
|
+
rate: number;
|
|
11
|
+
period: number;
|
|
12
|
+
capacity?: number;
|
|
13
|
+
start?: number;
|
|
14
|
+
maxReserved?: number;
|
|
15
|
+
};
|
|
16
|
+
export type RateLimitConfig = TokenBucket | FixedWindow;
|
|
17
|
+
/** Stored per (name, key): tokens left and the time they were computed for (window start for fixed windows). */
|
|
18
|
+
export type RateLimitState = {
|
|
19
|
+
value: number;
|
|
20
|
+
ts: number;
|
|
21
|
+
};
|
|
22
|
+
export type RateLimitResult = {
|
|
23
|
+
ok: true;
|
|
24
|
+
retryAfter?: undefined;
|
|
25
|
+
}
|
|
26
|
+
/** `retryAfter`: ms until `count` tokens are available (or, with `reserve`, until the reservation is valid). */
|
|
27
|
+
| {
|
|
28
|
+
ok: false;
|
|
29
|
+
retryAfter: number;
|
|
30
|
+
};
|
|
31
|
+
export type ReserveResult = {
|
|
32
|
+
ok: true;
|
|
33
|
+
retryAfter?: number;
|
|
34
|
+
} | {
|
|
35
|
+
ok: false;
|
|
36
|
+
retryAfter: number;
|
|
37
|
+
};
|
|
38
|
+
export declare const SECOND = 1000;
|
|
39
|
+
export declare const MINUTE: number;
|
|
40
|
+
export declare const HOUR: number;
|
|
41
|
+
export declare const DAY: number;
|
|
42
|
+
export declare function checkConfig(c: RateLimitConfig): void;
|
|
43
|
+
/** The state as of `now` (refilled), without consuming anything. `prev` null = never used: a full bucket. */
|
|
44
|
+
export declare function refill(c: RateLimitConfig, prev: RateLimitState | null, now: number): RateLimitState;
|
|
45
|
+
/**
|
|
46
|
+
* Consumes `count` tokens if available. Returns the decision and the state to store (unchanged on refusal:
|
|
47
|
+
* a refused call costs nothing).
|
|
48
|
+
*/
|
|
49
|
+
export declare function consume(c: RateLimitConfig, prev: RateLimitState | null, now: number, count?: number, reserve?: boolean): {
|
|
50
|
+
result: ReserveResult;
|
|
51
|
+
state: RateLimitState | null;
|
|
52
|
+
};
|
|
53
|
+
/** Whether `count` tokens are available now, without consuming them. */
|
|
54
|
+
export declare function check(c: RateLimitConfig, prev: RateLimitState | null, now: number, count?: number): RateLimitResult;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// Pure rate-limit math (no I/O, no clock): the component's mutation feeds it the stored state and `ctx.now`.
|
|
2
|
+
//
|
|
3
|
+
// - Token bucket: `capacity` tokens (default `rate`), refilled continuously at `rate` per `period` ms. Bursts up
|
|
4
|
+
// to `capacity`, then a steady `rate / period`.
|
|
5
|
+
// - Fixed window: `rate` tokens are added at the start of every window of `period` ms (windows aligned to
|
|
6
|
+
// `start`, default 0 = the epoch), up to `capacity` (default `rate`: unused tokens do not carry over).
|
|
7
|
+
//
|
|
8
|
+
// `reserve: true` takes the tokens even when there are not enough (the value goes negative) and reports when the
|
|
9
|
+
// reservation becomes valid (`retryAfter`): the caller schedules its work at that time. `maxReserved` caps how
|
|
10
|
+
// far below zero reservations may go.
|
|
11
|
+
export const SECOND = 1000;
|
|
12
|
+
export const MINUTE = 60 * SECOND;
|
|
13
|
+
export const HOUR = 60 * MINUTE;
|
|
14
|
+
export const DAY = 24 * HOUR;
|
|
15
|
+
export function checkConfig(c) {
|
|
16
|
+
if (!c || (c.kind !== "token bucket" && c.kind !== "fixed window"))
|
|
17
|
+
throw new Error(`rate limit: kind must be "token bucket" or "fixed window"`);
|
|
18
|
+
if (!(c.rate > 0) || !Number.isFinite(c.rate))
|
|
19
|
+
throw new Error("rate limit: rate must be a positive number");
|
|
20
|
+
if (!(c.period > 0) || !Number.isFinite(c.period))
|
|
21
|
+
throw new Error("rate limit: period must be a positive number of ms");
|
|
22
|
+
if (c.capacity !== undefined && (!(c.capacity > 0) || !Number.isFinite(c.capacity)))
|
|
23
|
+
throw new Error("rate limit: capacity must be a positive finite number");
|
|
24
|
+
if (c.maxReserved !== undefined && (!(c.maxReserved >= 0) || !Number.isFinite(c.maxReserved)))
|
|
25
|
+
throw new Error("rate limit: maxReserved must be a finite number >= 0");
|
|
26
|
+
if (c.kind === "fixed window" && c.start !== undefined && !Number.isFinite(c.start))
|
|
27
|
+
throw new Error("rate limit: start must be a finite number of ms");
|
|
28
|
+
}
|
|
29
|
+
const capacityOf = (c) => c.capacity ?? c.rate;
|
|
30
|
+
/** `count` must be a finite number in [0, capacity] (more than capacity could never succeed). */
|
|
31
|
+
function checkCount(c, count) {
|
|
32
|
+
if (!(count >= 0) || !Number.isFinite(count))
|
|
33
|
+
throw new Error("rate limit: count must be a non-negative number");
|
|
34
|
+
if (count > capacityOf(c))
|
|
35
|
+
throw new Error(`rate limit: count ${count} exceeds capacity ${capacityOf(c)}`);
|
|
36
|
+
}
|
|
37
|
+
/** The state as of `now` (refilled), without consuming anything. `prev` null = never used: a full bucket. */
|
|
38
|
+
export function refill(c, prev, now) {
|
|
39
|
+
const cap = capacityOf(c);
|
|
40
|
+
if (c.kind === "token bucket") {
|
|
41
|
+
if (!prev)
|
|
42
|
+
return { value: cap, ts: now };
|
|
43
|
+
const elapsed = Math.max(0, now - prev.ts);
|
|
44
|
+
return { value: Math.min(cap, prev.value + (elapsed * c.rate) / c.period), ts: Math.max(now, prev.ts) };
|
|
45
|
+
}
|
|
46
|
+
const start = c.start ?? 0;
|
|
47
|
+
const windowStart = now - (((now - start) % c.period) + c.period) % c.period;
|
|
48
|
+
if (!prev)
|
|
49
|
+
return { value: cap, ts: windowStart };
|
|
50
|
+
const windows = Math.max(0, Math.floor((windowStart - prev.ts) / c.period));
|
|
51
|
+
return { value: Math.min(cap, prev.value + windows * c.rate), ts: Math.max(prev.ts, windowStart) };
|
|
52
|
+
}
|
|
53
|
+
/** Time until `value` reaches `target` (ms; 0 if already there). */
|
|
54
|
+
function timeUntil(c, s, target, now) {
|
|
55
|
+
if (s.value >= target)
|
|
56
|
+
return 0;
|
|
57
|
+
const missing = target - s.value;
|
|
58
|
+
if (c.kind === "token bucket")
|
|
59
|
+
return Math.ceil((missing * c.period) / c.rate);
|
|
60
|
+
// Fixed window: tokens arrive `rate` at a time, at window boundaries.
|
|
61
|
+
const windows = Math.ceil(missing / c.rate);
|
|
62
|
+
return Math.max(0, s.ts + windows * c.period - now);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Consumes `count` tokens if available. Returns the decision and the state to store (unchanged on refusal:
|
|
66
|
+
* a refused call costs nothing).
|
|
67
|
+
*/
|
|
68
|
+
export function consume(c, prev, now, count = 1, reserve = false) {
|
|
69
|
+
checkConfig(c);
|
|
70
|
+
checkCount(c, count);
|
|
71
|
+
const s = refill(c, prev, now);
|
|
72
|
+
if (s.value >= count)
|
|
73
|
+
return { result: { ok: true }, state: { value: s.value - count, ts: s.ts } };
|
|
74
|
+
if (reserve) {
|
|
75
|
+
const next = s.value - count;
|
|
76
|
+
if (c.maxReserved === undefined || -next <= c.maxReserved) {
|
|
77
|
+
return { result: { ok: true, retryAfter: timeUntil(c, s, count, now) }, state: { value: next, ts: s.ts } };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return { result: { ok: false, retryAfter: timeUntil(c, s, count, now) }, state: null };
|
|
81
|
+
}
|
|
82
|
+
/** Whether `count` tokens are available now, without consuming them. */
|
|
83
|
+
export function check(c, prev, now, count = 1) {
|
|
84
|
+
checkConfig(c);
|
|
85
|
+
checkCount(c, count);
|
|
86
|
+
const s = refill(c, prev, now);
|
|
87
|
+
return s.value >= count ? { ok: true } : { ok: false, retryAfter: timeUntil(c, s, count, now) };
|
|
88
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { type RateLimitConfig } from "./algorithms.ts";
|
|
2
|
+
export declare const rateLimitConfigValidator: import("@freecorps/ember-values").Validator<{
|
|
3
|
+
kind: "token bucket";
|
|
4
|
+
rate: number;
|
|
5
|
+
period: number;
|
|
6
|
+
capacity?: number;
|
|
7
|
+
maxReserved?: number;
|
|
8
|
+
} | {
|
|
9
|
+
kind: "fixed window";
|
|
10
|
+
rate: number;
|
|
11
|
+
period: number;
|
|
12
|
+
start?: number;
|
|
13
|
+
capacity?: number;
|
|
14
|
+
maxReserved?: number;
|
|
15
|
+
}, "required", NonNullable<"start" | "kind" | "rate" | "period" | "capacity" | "maxReserved" | undefined>>;
|
|
16
|
+
/** Install config: named limits (so calls can pass only the name) and an optional Cloudflare binding. */
|
|
17
|
+
export declare const installConfigValidator: import("@freecorps/ember-values").Validator<{
|
|
18
|
+
limits?: Record<string, {
|
|
19
|
+
kind: "token bucket";
|
|
20
|
+
rate: number;
|
|
21
|
+
period: number;
|
|
22
|
+
capacity?: number;
|
|
23
|
+
maxReserved?: number;
|
|
24
|
+
} | {
|
|
25
|
+
kind: "fixed window";
|
|
26
|
+
rate: number;
|
|
27
|
+
period: number;
|
|
28
|
+
start?: number;
|
|
29
|
+
capacity?: number;
|
|
30
|
+
maxReserved?: number;
|
|
31
|
+
}>;
|
|
32
|
+
binding?: {
|
|
33
|
+
name: string;
|
|
34
|
+
period: 60 | 10;
|
|
35
|
+
limit: number;
|
|
36
|
+
};
|
|
37
|
+
}, "required", import("@freecorps/ember-values").ObjectFieldPaths<{
|
|
38
|
+
limits: import("@freecorps/ember-values").Validator<Record<string, {
|
|
39
|
+
kind: "token bucket";
|
|
40
|
+
rate: number;
|
|
41
|
+
period: number;
|
|
42
|
+
capacity?: number;
|
|
43
|
+
maxReserved?: number;
|
|
44
|
+
} | {
|
|
45
|
+
kind: "fixed window";
|
|
46
|
+
rate: number;
|
|
47
|
+
period: number;
|
|
48
|
+
start?: number;
|
|
49
|
+
capacity?: number;
|
|
50
|
+
maxReserved?: number;
|
|
51
|
+
}>, "optional", never>;
|
|
52
|
+
/**
|
|
53
|
+
* A Workers Rate Limiting binding `ember deploy` creates (period 10 or 60 s). Approximate and per location
|
|
54
|
+
* (Cloudflare docs): for cheap abuse protection in actions/HTTP before touching a shard, not exact quotas.
|
|
55
|
+
*/
|
|
56
|
+
binding: import("@freecorps/ember-values").Validator<{
|
|
57
|
+
name: string;
|
|
58
|
+
period: 60 | 10;
|
|
59
|
+
limit: number;
|
|
60
|
+
}, "optional", NonNullable<import("@freecorps/ember-values").ObjectFieldPaths<{
|
|
61
|
+
name: import("@freecorps/ember-values").Validator<string, "required", never>;
|
|
62
|
+
limit: import("@freecorps/ember-values").Validator<number, "required", never>;
|
|
63
|
+
period: import("@freecorps/ember-values").Validator<60 | 10, "required", never>;
|
|
64
|
+
}> | undefined>>;
|
|
65
|
+
}>>;
|
|
66
|
+
export type RateLimitInstallConfig = {
|
|
67
|
+
limits?: Record<string, RateLimitConfig>;
|
|
68
|
+
binding?: {
|
|
69
|
+
name: string;
|
|
70
|
+
limit: number;
|
|
71
|
+
period: 10 | 60;
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
/** Thrown by `limit({ throws: true })` (and `RateLimiter.limit(..., { throws: true })`): `data = { name, retryAfter }`. */
|
|
75
|
+
export declare const RATE_LIMITED = "RateLimited";
|
|
76
|
+
export declare const functions: {
|
|
77
|
+
lib: {
|
|
78
|
+
/**
|
|
79
|
+
* Takes `count` tokens of `name`/`key`. `{ ok: true }` or `{ ok: false, retryAfter }` (ms). With `reserve`,
|
|
80
|
+
* takes them anyway (going negative) and says when the reservation is valid. With `throws`, a refusal
|
|
81
|
+
* throws `RateLimited` instead (an app mutation can let it propagate to the client).
|
|
82
|
+
*/
|
|
83
|
+
limit: import("@freecorps/ember-server").RegisteredMutation<"public", {
|
|
84
|
+
name: string;
|
|
85
|
+
key?: string;
|
|
86
|
+
reserve?: boolean;
|
|
87
|
+
throws?: boolean;
|
|
88
|
+
count?: number;
|
|
89
|
+
config?: {
|
|
90
|
+
kind: "token bucket";
|
|
91
|
+
rate: number;
|
|
92
|
+
period: number;
|
|
93
|
+
capacity?: number;
|
|
94
|
+
maxReserved?: number;
|
|
95
|
+
} | {
|
|
96
|
+
kind: "fixed window";
|
|
97
|
+
rate: number;
|
|
98
|
+
period: number;
|
|
99
|
+
start?: number;
|
|
100
|
+
capacity?: number;
|
|
101
|
+
maxReserved?: number;
|
|
102
|
+
};
|
|
103
|
+
}, {
|
|
104
|
+
ok: boolean;
|
|
105
|
+
retryAfter: number;
|
|
106
|
+
} | {
|
|
107
|
+
ok: boolean;
|
|
108
|
+
retryAfter?: never;
|
|
109
|
+
}>;
|
|
110
|
+
/** Whether `count` tokens are available now, without taking them (reactive in queries). */
|
|
111
|
+
check: import("@freecorps/ember-server").RegisteredQuery<"public", {
|
|
112
|
+
name: string;
|
|
113
|
+
key?: string;
|
|
114
|
+
count?: number;
|
|
115
|
+
config?: {
|
|
116
|
+
kind: "token bucket";
|
|
117
|
+
rate: number;
|
|
118
|
+
period: number;
|
|
119
|
+
capacity?: number;
|
|
120
|
+
maxReserved?: number;
|
|
121
|
+
} | {
|
|
122
|
+
kind: "fixed window";
|
|
123
|
+
rate: number;
|
|
124
|
+
period: number;
|
|
125
|
+
start?: number;
|
|
126
|
+
capacity?: number;
|
|
127
|
+
maxReserved?: number;
|
|
128
|
+
};
|
|
129
|
+
}, {
|
|
130
|
+
ok: boolean;
|
|
131
|
+
retryAfter?: never;
|
|
132
|
+
} | {
|
|
133
|
+
ok: boolean;
|
|
134
|
+
retryAfter: number;
|
|
135
|
+
}>;
|
|
136
|
+
/** Tokens available now (may be negative after reservations) and the capacity, e.g. for a UI meter. */
|
|
137
|
+
getValue: import("@freecorps/ember-server").RegisteredQuery<"public", {
|
|
138
|
+
name: string;
|
|
139
|
+
key?: string;
|
|
140
|
+
config?: {
|
|
141
|
+
kind: "token bucket";
|
|
142
|
+
rate: number;
|
|
143
|
+
period: number;
|
|
144
|
+
capacity?: number;
|
|
145
|
+
maxReserved?: number;
|
|
146
|
+
} | {
|
|
147
|
+
kind: "fixed window";
|
|
148
|
+
rate: number;
|
|
149
|
+
period: number;
|
|
150
|
+
start?: number;
|
|
151
|
+
capacity?: number;
|
|
152
|
+
maxReserved?: number;
|
|
153
|
+
};
|
|
154
|
+
}, {
|
|
155
|
+
value: number;
|
|
156
|
+
capacity: number;
|
|
157
|
+
ts: number;
|
|
158
|
+
}>;
|
|
159
|
+
/** Forgets `name`/`key` (back to a full bucket). Returns whether a row existed. */
|
|
160
|
+
reset: import("@freecorps/ember-server").RegisteredMutation<"public", {
|
|
161
|
+
name: string;
|
|
162
|
+
key?: string;
|
|
163
|
+
}, boolean>;
|
|
164
|
+
};
|
|
165
|
+
};
|
|
166
|
+
export declare const rateLimit: import("@freecorps/ember-server").ComponentDefinition<"rateLimit", {
|
|
167
|
+
lib: {
|
|
168
|
+
/**
|
|
169
|
+
* Takes `count` tokens of `name`/`key`. `{ ok: true }` or `{ ok: false, retryAfter }` (ms). With `reserve`,
|
|
170
|
+
* takes them anyway (going negative) and says when the reservation is valid. With `throws`, a refusal
|
|
171
|
+
* throws `RateLimited` instead (an app mutation can let it propagate to the client).
|
|
172
|
+
*/
|
|
173
|
+
limit: import("@freecorps/ember-server").RegisteredMutation<"public", {
|
|
174
|
+
name: string;
|
|
175
|
+
key?: string;
|
|
176
|
+
reserve?: boolean;
|
|
177
|
+
throws?: boolean;
|
|
178
|
+
count?: number;
|
|
179
|
+
config?: {
|
|
180
|
+
kind: "token bucket";
|
|
181
|
+
rate: number;
|
|
182
|
+
period: number;
|
|
183
|
+
capacity?: number;
|
|
184
|
+
maxReserved?: number;
|
|
185
|
+
} | {
|
|
186
|
+
kind: "fixed window";
|
|
187
|
+
rate: number;
|
|
188
|
+
period: number;
|
|
189
|
+
start?: number;
|
|
190
|
+
capacity?: number;
|
|
191
|
+
maxReserved?: number;
|
|
192
|
+
};
|
|
193
|
+
}, {
|
|
194
|
+
ok: boolean;
|
|
195
|
+
retryAfter: number;
|
|
196
|
+
} | {
|
|
197
|
+
ok: boolean;
|
|
198
|
+
retryAfter?: never;
|
|
199
|
+
}>;
|
|
200
|
+
/** Whether `count` tokens are available now, without taking them (reactive in queries). */
|
|
201
|
+
check: import("@freecorps/ember-server").RegisteredQuery<"public", {
|
|
202
|
+
name: string;
|
|
203
|
+
key?: string;
|
|
204
|
+
count?: number;
|
|
205
|
+
config?: {
|
|
206
|
+
kind: "token bucket";
|
|
207
|
+
rate: number;
|
|
208
|
+
period: number;
|
|
209
|
+
capacity?: number;
|
|
210
|
+
maxReserved?: number;
|
|
211
|
+
} | {
|
|
212
|
+
kind: "fixed window";
|
|
213
|
+
rate: number;
|
|
214
|
+
period: number;
|
|
215
|
+
start?: number;
|
|
216
|
+
capacity?: number;
|
|
217
|
+
maxReserved?: number;
|
|
218
|
+
};
|
|
219
|
+
}, {
|
|
220
|
+
ok: boolean;
|
|
221
|
+
retryAfter?: never;
|
|
222
|
+
} | {
|
|
223
|
+
ok: boolean;
|
|
224
|
+
retryAfter: number;
|
|
225
|
+
}>;
|
|
226
|
+
/** Tokens available now (may be negative after reservations) and the capacity, e.g. for a UI meter. */
|
|
227
|
+
getValue: import("@freecorps/ember-server").RegisteredQuery<"public", {
|
|
228
|
+
name: string;
|
|
229
|
+
key?: string;
|
|
230
|
+
config?: {
|
|
231
|
+
kind: "token bucket";
|
|
232
|
+
rate: number;
|
|
233
|
+
period: number;
|
|
234
|
+
capacity?: number;
|
|
235
|
+
maxReserved?: number;
|
|
236
|
+
} | {
|
|
237
|
+
kind: "fixed window";
|
|
238
|
+
rate: number;
|
|
239
|
+
period: number;
|
|
240
|
+
start?: number;
|
|
241
|
+
capacity?: number;
|
|
242
|
+
maxReserved?: number;
|
|
243
|
+
};
|
|
244
|
+
}, {
|
|
245
|
+
value: number;
|
|
246
|
+
capacity: number;
|
|
247
|
+
ts: number;
|
|
248
|
+
}>;
|
|
249
|
+
/** Forgets `name`/`key` (back to a full bucket). Returns whether a row existed. */
|
|
250
|
+
reset: import("@freecorps/ember-server").RegisteredMutation<"public", {
|
|
251
|
+
name: string;
|
|
252
|
+
key?: string;
|
|
253
|
+
}, boolean>;
|
|
254
|
+
};
|
|
255
|
+
}, {
|
|
256
|
+
limits?: Record<string, {
|
|
257
|
+
kind: "token bucket";
|
|
258
|
+
rate: number;
|
|
259
|
+
period: number;
|
|
260
|
+
capacity?: number;
|
|
261
|
+
maxReserved?: number;
|
|
262
|
+
} | {
|
|
263
|
+
kind: "fixed window";
|
|
264
|
+
rate: number;
|
|
265
|
+
period: number;
|
|
266
|
+
start?: number;
|
|
267
|
+
capacity?: number;
|
|
268
|
+
maxReserved?: number;
|
|
269
|
+
}>;
|
|
270
|
+
binding?: {
|
|
271
|
+
name: string;
|
|
272
|
+
period: 60 | 10;
|
|
273
|
+
limit: number;
|
|
274
|
+
};
|
|
275
|
+
}, {}>;
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// The rateLimit component (PLAN §12): one table of (name, key) → tokens, in the caller's shard. `lib:limit` is a
|
|
2
|
+
// mutation, so inside an app mutation it runs in the same transaction: if the app mutation fails afterwards, the
|
|
3
|
+
// tokens are given back (nothing was consumed). Refused calls write nothing.
|
|
4
|
+
import { componentBuilders, defineComponent, defineSchema, defineTable, EmberError, v } from "@freecorps/ember-server";
|
|
5
|
+
import { check, checkConfig, consume, refill } from "./algorithms.js";
|
|
6
|
+
const schema = defineSchema({
|
|
7
|
+
tables: {
|
|
8
|
+
/** One row per (name, key) that has been limited; `key` "" is the global limit of `name`. */
|
|
9
|
+
rateLimits: defineTable({ name: v.string(), key: v.string(), value: v.number(), ts: v.number() }).index("by_name_key", ["name", "key"]),
|
|
10
|
+
},
|
|
11
|
+
});
|
|
12
|
+
export const rateLimitConfigValidator = v.union(v.object({
|
|
13
|
+
kind: v.literal("token bucket"),
|
|
14
|
+
rate: v.number(),
|
|
15
|
+
period: v.number(),
|
|
16
|
+
capacity: v.optional(v.number()),
|
|
17
|
+
maxReserved: v.optional(v.number()),
|
|
18
|
+
}), v.object({
|
|
19
|
+
kind: v.literal("fixed window"),
|
|
20
|
+
rate: v.number(),
|
|
21
|
+
period: v.number(),
|
|
22
|
+
capacity: v.optional(v.number()),
|
|
23
|
+
start: v.optional(v.number()),
|
|
24
|
+
maxReserved: v.optional(v.number()),
|
|
25
|
+
}));
|
|
26
|
+
/** Install config: named limits (so calls can pass only the name) and an optional Cloudflare binding. */
|
|
27
|
+
export const installConfigValidator = v.object({
|
|
28
|
+
limits: v.optional(v.record(rateLimitConfigValidator)),
|
|
29
|
+
/**
|
|
30
|
+
* A Workers Rate Limiting binding `ember deploy` creates (period 10 or 60 s). Approximate and per location
|
|
31
|
+
* (Cloudflare docs): for cheap abuse protection in actions/HTTP before touching a shard, not exact quotas.
|
|
32
|
+
*/
|
|
33
|
+
binding: v.optional(v.object({ name: v.string(), limit: v.number(), period: v.union(v.literal(10), v.literal(60)) })),
|
|
34
|
+
});
|
|
35
|
+
const { query, mutation } = componentBuilders();
|
|
36
|
+
const callArgs = {
|
|
37
|
+
/** Limit name ("sendEmail"). */
|
|
38
|
+
name: v.string(),
|
|
39
|
+
/** Per-user/tenant/... key; omitted = one global bucket for `name`. */
|
|
40
|
+
key: v.optional(v.string()),
|
|
41
|
+
/** Tokens to take (default 1). */
|
|
42
|
+
count: v.optional(v.number()),
|
|
43
|
+
/** The limit's config; omitted = the install config's `limits[name]`. */
|
|
44
|
+
config: v.optional(rateLimitConfigValidator),
|
|
45
|
+
};
|
|
46
|
+
function configFor(ctx, name, given) {
|
|
47
|
+
const c = given ?? ctx.component.config?.limits?.[name];
|
|
48
|
+
if (!c)
|
|
49
|
+
throw new EmberError("ValidationError", `rateLimit: no config for limit ${JSON.stringify(name)} (pass config, or install it with { limits })`);
|
|
50
|
+
try {
|
|
51
|
+
checkConfig(c);
|
|
52
|
+
}
|
|
53
|
+
catch (e) {
|
|
54
|
+
throw new EmberError("ValidationError", e.message);
|
|
55
|
+
}
|
|
56
|
+
return c;
|
|
57
|
+
}
|
|
58
|
+
const row = (ctx, name, key) => ctx.db.query("rateLimits").withIndex("by_name_key", (q) => q.eq("name", name).eq("key", key)).unique();
|
|
59
|
+
/** Thrown by `limit({ throws: true })` (and `RateLimiter.limit(..., { throws: true })`): `data = { name, retryAfter }`. */
|
|
60
|
+
export const RATE_LIMITED = "RateLimited";
|
|
61
|
+
export const functions = {
|
|
62
|
+
lib: {
|
|
63
|
+
/**
|
|
64
|
+
* Takes `count` tokens of `name`/`key`. `{ ok: true }` or `{ ok: false, retryAfter }` (ms). With `reserve`,
|
|
65
|
+
* takes them anyway (going negative) and says when the reservation is valid. With `throws`, a refusal
|
|
66
|
+
* throws `RateLimited` instead (an app mutation can let it propagate to the client).
|
|
67
|
+
*/
|
|
68
|
+
limit: mutation({
|
|
69
|
+
args: { ...callArgs, reserve: v.optional(v.boolean()), throws: v.optional(v.boolean()) },
|
|
70
|
+
returns: v.object({ ok: v.boolean(), retryAfter: v.optional(v.number()) }),
|
|
71
|
+
handler: (ctx, a) => {
|
|
72
|
+
const config = configFor(ctx, a.name, a.config);
|
|
73
|
+
const key = a.key ?? "";
|
|
74
|
+
const cur = row(ctx, a.name, key);
|
|
75
|
+
let decision;
|
|
76
|
+
try {
|
|
77
|
+
decision = consume(config, cur, ctx.now, a.count ?? 1, a.reserve ?? false);
|
|
78
|
+
}
|
|
79
|
+
catch (e) {
|
|
80
|
+
throw new EmberError("ValidationError", e.message);
|
|
81
|
+
}
|
|
82
|
+
const { result, state } = decision;
|
|
83
|
+
if (!result.ok) {
|
|
84
|
+
if (a.throws) {
|
|
85
|
+
throw new EmberError(RATE_LIMITED, `Rate limit ${a.name} exceeded; retry in ${result.retryAfter} ms`, { name: a.name, retryAfter: result.retryAfter });
|
|
86
|
+
}
|
|
87
|
+
return { ok: false, retryAfter: result.retryAfter };
|
|
88
|
+
}
|
|
89
|
+
if (state) {
|
|
90
|
+
if (cur)
|
|
91
|
+
ctx.db.patch(cur._id, { value: state.value, ts: state.ts });
|
|
92
|
+
else
|
|
93
|
+
ctx.db.insert("rateLimits", { name: a.name, key, value: state.value, ts: state.ts });
|
|
94
|
+
}
|
|
95
|
+
return result.retryAfter !== undefined ? { ok: true, retryAfter: result.retryAfter } : { ok: true };
|
|
96
|
+
},
|
|
97
|
+
}),
|
|
98
|
+
/** Whether `count` tokens are available now, without taking them (reactive in queries). */
|
|
99
|
+
check: query({
|
|
100
|
+
args: callArgs,
|
|
101
|
+
returns: v.object({ ok: v.boolean(), retryAfter: v.optional(v.number()) }),
|
|
102
|
+
handler: (ctx, a) => {
|
|
103
|
+
const config = configFor(ctx, a.name, a.config);
|
|
104
|
+
let r;
|
|
105
|
+
try {
|
|
106
|
+
r = check(config, row(ctx, a.name, a.key ?? ""), ctx.now, a.count ?? 1);
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
throw new EmberError("ValidationError", e.message);
|
|
110
|
+
}
|
|
111
|
+
return r.ok ? { ok: true } : { ok: false, retryAfter: r.retryAfter };
|
|
112
|
+
},
|
|
113
|
+
}),
|
|
114
|
+
/** Tokens available now (may be negative after reservations) and the capacity, e.g. for a UI meter. */
|
|
115
|
+
getValue: query({
|
|
116
|
+
args: { name: v.string(), key: v.optional(v.string()), config: v.optional(rateLimitConfigValidator) },
|
|
117
|
+
returns: v.object({ value: v.number(), capacity: v.number(), ts: v.number() }),
|
|
118
|
+
handler: (ctx, a) => {
|
|
119
|
+
const config = configFor(ctx, a.name, a.config);
|
|
120
|
+
const s = refill(config, row(ctx, a.name, a.key ?? ""), ctx.now);
|
|
121
|
+
return { value: s.value, capacity: config.capacity ?? config.rate, ts: s.ts };
|
|
122
|
+
},
|
|
123
|
+
}),
|
|
124
|
+
/** Forgets `name`/`key` (back to a full bucket). Returns whether a row existed. */
|
|
125
|
+
reset: mutation({
|
|
126
|
+
args: { name: v.string(), key: v.optional(v.string()) },
|
|
127
|
+
returns: v.boolean(),
|
|
128
|
+
handler: (ctx, a) => {
|
|
129
|
+
const cur = row(ctx, a.name, a.key ?? "");
|
|
130
|
+
if (cur)
|
|
131
|
+
ctx.db.delete(cur._id);
|
|
132
|
+
return !!cur;
|
|
133
|
+
},
|
|
134
|
+
}),
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
export const rateLimit = defineComponent({
|
|
138
|
+
name: "rateLimit",
|
|
139
|
+
schema,
|
|
140
|
+
functions,
|
|
141
|
+
config: v.optional(installConfigValidator),
|
|
142
|
+
bindings: (config) => (config?.binding ? [{ type: "ratelimit", name: config.binding.name, limit: config.binding.limit, period: config.binding.period }] : []),
|
|
143
|
+
});
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@freecorps/ember-rate-limit`: rate limiting as an Ember component (PLAN §12), like Convex's rate-limiter.
|
|
3
|
+
*
|
|
4
|
+
* // ember/ember.config.ts
|
|
5
|
+
* export default defineApp().use(rateLimit);
|
|
6
|
+
*
|
|
7
|
+
* // ember/limits.ts
|
|
8
|
+
* export const limiter = new RateLimiter(components.rateLimit, {
|
|
9
|
+
* sendMessage: { kind: "token bucket", rate: 10, period: MINUTE, capacity: 3 },
|
|
10
|
+
* signUp: { kind: "fixed window", rate: 100, period: HOUR },
|
|
11
|
+
* });
|
|
12
|
+
*
|
|
13
|
+
* // in a mutation (synchronous, same transaction: a later failure gives the tokens back)
|
|
14
|
+
* const { ok, retryAfter } = limiter.limit(ctx, "sendMessage", { key: userId });
|
|
15
|
+
* // in an action (a separate transaction on the action's shard)
|
|
16
|
+
* await limiter.limit(ctx, "signUp");
|
|
17
|
+
*
|
|
18
|
+
* State lives in the caller's shard: a limit is exact per shard. For a limit across shards, call it on one shard
|
|
19
|
+
* from an action (`ctx.onShard("global:main")`), or use the Cloudflare binding (approximate, per location).
|
|
20
|
+
*/
|
|
21
|
+
import { type FunctionReference } from "@freecorps/ember-server";
|
|
22
|
+
import type { RateLimitConfig, ReserveResult } from "./algorithms.ts";
|
|
23
|
+
export * from "./algorithms.ts";
|
|
24
|
+
export { rateLimit, functions as rateLimitFunctions, rateLimitConfigValidator, installConfigValidator, RATE_LIMITED, type RateLimitInstallConfig } from "./component.ts";
|
|
25
|
+
type LimitArgs = {
|
|
26
|
+
name: string;
|
|
27
|
+
key?: string;
|
|
28
|
+
count?: number;
|
|
29
|
+
config?: RateLimitConfig;
|
|
30
|
+
reserve?: boolean;
|
|
31
|
+
throws?: boolean;
|
|
32
|
+
};
|
|
33
|
+
type CheckArgs = {
|
|
34
|
+
name: string;
|
|
35
|
+
key?: string;
|
|
36
|
+
count?: number;
|
|
37
|
+
config?: RateLimitConfig;
|
|
38
|
+
};
|
|
39
|
+
type Result = {
|
|
40
|
+
ok: boolean;
|
|
41
|
+
retryAfter?: number;
|
|
42
|
+
};
|
|
43
|
+
/** What `components.<name>` of the rateLimit component looks like (structural: any install name works). */
|
|
44
|
+
export interface RateLimitComponentApi {
|
|
45
|
+
lib: {
|
|
46
|
+
limit: FunctionReference<"mutation", "public", LimitArgs, Result>;
|
|
47
|
+
check: FunctionReference<"query", "public", CheckArgs, Result>;
|
|
48
|
+
getValue: FunctionReference<"query", "public", {
|
|
49
|
+
name: string;
|
|
50
|
+
key?: string;
|
|
51
|
+
config?: RateLimitConfig;
|
|
52
|
+
}, {
|
|
53
|
+
value: number;
|
|
54
|
+
capacity: number;
|
|
55
|
+
ts: number;
|
|
56
|
+
}>;
|
|
57
|
+
reset: FunctionReference<"mutation", "public", {
|
|
58
|
+
name: string;
|
|
59
|
+
key?: string;
|
|
60
|
+
}, boolean>;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** Mutation/query ctx: `ctx.run*` returns values (same transaction). */
|
|
64
|
+
type SyncCtx = {
|
|
65
|
+
db: unknown;
|
|
66
|
+
runQuery(fn: any, args?: any): any;
|
|
67
|
+
runMutation?(fn: any, args?: any): any;
|
|
68
|
+
};
|
|
69
|
+
/** Action ctx: `ctx.run*` returns Promises (own transaction on the action's shard). */
|
|
70
|
+
type AsyncCtx = {
|
|
71
|
+
runAction(fn: any, args?: any): Promise<any>;
|
|
72
|
+
runQuery(fn: any, args?: any): Promise<any>;
|
|
73
|
+
runMutation(fn: any, args?: any): Promise<any>;
|
|
74
|
+
};
|
|
75
|
+
export type LimitOptions = {
|
|
76
|
+
key?: string;
|
|
77
|
+
count?: number;
|
|
78
|
+
reserve?: boolean;
|
|
79
|
+
throws?: boolean;
|
|
80
|
+
};
|
|
81
|
+
/** Throws `RateLimited` for a refused result (what `throws: true` does inside the component). */
|
|
82
|
+
export declare function assertAllowed(name: string, r: Result): void;
|
|
83
|
+
/**
|
|
84
|
+
* Typed helper over an installed rateLimit component: limits are declared once here and sent with each call,
|
|
85
|
+
* so they can change without redeploying the component's config. Works in mutations (sync) and actions (async).
|
|
86
|
+
*/
|
|
87
|
+
export declare class RateLimiter<Limits extends Record<string, RateLimitConfig>> {
|
|
88
|
+
readonly component: RateLimitComponentApi;
|
|
89
|
+
readonly limits: Limits;
|
|
90
|
+
constructor(component: RateLimitComponentApi, limits: Limits);
|
|
91
|
+
private args;
|
|
92
|
+
/** Takes tokens. In a mutation: synchronous, same transaction. In an action: a Promise. */
|
|
93
|
+
limit(ctx: SyncCtx, name: keyof Limits & string, opts?: LimitOptions): ReserveResult;
|
|
94
|
+
limit(ctx: AsyncCtx, name: keyof Limits & string, opts?: LimitOptions): Promise<ReserveResult>;
|
|
95
|
+
/** Checks without taking tokens. Reactive inside queries. */
|
|
96
|
+
check(ctx: SyncCtx, name: keyof Limits & string, opts?: Pick<LimitOptions, "key" | "count">): Result;
|
|
97
|
+
check(ctx: AsyncCtx, name: keyof Limits & string, opts?: Pick<LimitOptions, "key" | "count">): Promise<Result>;
|
|
98
|
+
/** Tokens left (and capacity), e.g. for a UI. */
|
|
99
|
+
getValue(ctx: SyncCtx, name: keyof Limits & string, opts?: {
|
|
100
|
+
key?: string;
|
|
101
|
+
}): {
|
|
102
|
+
value: number;
|
|
103
|
+
capacity: number;
|
|
104
|
+
ts: number;
|
|
105
|
+
};
|
|
106
|
+
getValue(ctx: AsyncCtx, name: keyof Limits & string, opts?: {
|
|
107
|
+
key?: string;
|
|
108
|
+
}): Promise<{
|
|
109
|
+
value: number;
|
|
110
|
+
capacity: number;
|
|
111
|
+
ts: number;
|
|
112
|
+
}>;
|
|
113
|
+
/** Back to a full bucket. */
|
|
114
|
+
reset(ctx: SyncCtx, name: keyof Limits & string, opts?: {
|
|
115
|
+
key?: string;
|
|
116
|
+
}): boolean;
|
|
117
|
+
reset(ctx: AsyncCtx, name: keyof Limits & string, opts?: {
|
|
118
|
+
key?: string;
|
|
119
|
+
}): Promise<boolean>;
|
|
120
|
+
}
|
|
121
|
+
/** The Workers Rate Limiting binding (`env.<name>.limit({ key })`). */
|
|
122
|
+
export interface RateLimitBinding {
|
|
123
|
+
limit(o: {
|
|
124
|
+
key: string;
|
|
125
|
+
}): Promise<{
|
|
126
|
+
success: boolean;
|
|
127
|
+
}>;
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Action/HTTP-side check against the Cloudflare Rate Limiting binding the component asked for
|
|
131
|
+
* (`app.use(rateLimit, { config: { binding: { name, limit, period } } })`). Approximate and per Cloudflare
|
|
132
|
+
* location: use it to shed abusive traffic before it reaches a shard; use `RateLimiter` for exact quotas.
|
|
133
|
+
*/
|
|
134
|
+
export declare function edgeLimit(ctx: {
|
|
135
|
+
env: Record<string, unknown>;
|
|
136
|
+
}, binding: string, key: string): Promise<{
|
|
137
|
+
ok: boolean;
|
|
138
|
+
}>;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@freecorps/ember-rate-limit`: rate limiting as an Ember component (PLAN §12), like Convex's rate-limiter.
|
|
3
|
+
*
|
|
4
|
+
* // ember/ember.config.ts
|
|
5
|
+
* export default defineApp().use(rateLimit);
|
|
6
|
+
*
|
|
7
|
+
* // ember/limits.ts
|
|
8
|
+
* export const limiter = new RateLimiter(components.rateLimit, {
|
|
9
|
+
* sendMessage: { kind: "token bucket", rate: 10, period: MINUTE, capacity: 3 },
|
|
10
|
+
* signUp: { kind: "fixed window", rate: 100, period: HOUR },
|
|
11
|
+
* });
|
|
12
|
+
*
|
|
13
|
+
* // in a mutation (synchronous, same transaction: a later failure gives the tokens back)
|
|
14
|
+
* const { ok, retryAfter } = limiter.limit(ctx, "sendMessage", { key: userId });
|
|
15
|
+
* // in an action (a separate transaction on the action's shard)
|
|
16
|
+
* await limiter.limit(ctx, "signUp");
|
|
17
|
+
*
|
|
18
|
+
* State lives in the caller's shard: a limit is exact per shard. For a limit across shards, call it on one shard
|
|
19
|
+
* from an action (`ctx.onShard("global:main")`), or use the Cloudflare binding (approximate, per location).
|
|
20
|
+
*/
|
|
21
|
+
import { EmberError } from "@freecorps/ember-server";
|
|
22
|
+
import { RATE_LIMITED } from "./component.js";
|
|
23
|
+
export * from "./algorithms.js";
|
|
24
|
+
export { rateLimit, functions as rateLimitFunctions, rateLimitConfigValidator, installConfigValidator, RATE_LIMITED } from "./component.js";
|
|
25
|
+
/** Throws `RateLimited` for a refused result (what `throws: true` does inside the component). */
|
|
26
|
+
export function assertAllowed(name, r) {
|
|
27
|
+
if (!r.ok)
|
|
28
|
+
throw new EmberError(RATE_LIMITED, `Rate limit ${name} exceeded; retry in ${r.retryAfter} ms`, { name, retryAfter: r.retryAfter });
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Typed helper over an installed rateLimit component: limits are declared once here and sent with each call,
|
|
32
|
+
* so they can change without redeploying the component's config. Works in mutations (sync) and actions (async).
|
|
33
|
+
*/
|
|
34
|
+
export class RateLimiter {
|
|
35
|
+
component;
|
|
36
|
+
limits;
|
|
37
|
+
constructor(component, limits) {
|
|
38
|
+
this.component = component;
|
|
39
|
+
this.limits = limits;
|
|
40
|
+
}
|
|
41
|
+
args(name, o = {}) {
|
|
42
|
+
const config = this.limits[name];
|
|
43
|
+
if (!config)
|
|
44
|
+
throw new Error(`RateLimiter: unknown limit ${JSON.stringify(name)}`);
|
|
45
|
+
const a = { name, config };
|
|
46
|
+
if (o.key !== undefined)
|
|
47
|
+
a.key = o.key;
|
|
48
|
+
if (o.count !== undefined)
|
|
49
|
+
a.count = o.count;
|
|
50
|
+
if (o.reserve)
|
|
51
|
+
a.reserve = true;
|
|
52
|
+
if (o.throws)
|
|
53
|
+
a.throws = true;
|
|
54
|
+
return a;
|
|
55
|
+
}
|
|
56
|
+
limit(ctx, name, opts) {
|
|
57
|
+
if (!ctx.runMutation)
|
|
58
|
+
throw new Error("RateLimiter.limit needs a mutation or action ctx (queries cannot take tokens: use check)");
|
|
59
|
+
return ctx.runMutation(this.component.lib.limit, this.args(name, opts));
|
|
60
|
+
}
|
|
61
|
+
check(ctx, name, opts = {}) {
|
|
62
|
+
const { reserve: _r, throws: _t, ...a } = this.args(name, opts);
|
|
63
|
+
return ctx.runQuery(this.component.lib.check, a);
|
|
64
|
+
}
|
|
65
|
+
getValue(ctx, name, opts = {}) {
|
|
66
|
+
const { name: n, config, key } = this.args(name, opts);
|
|
67
|
+
return ctx.runQuery(this.component.lib.getValue, key !== undefined ? { name: n, config, key } : { name: n, config });
|
|
68
|
+
}
|
|
69
|
+
reset(ctx, name, opts = {}) {
|
|
70
|
+
if (!ctx.runMutation)
|
|
71
|
+
throw new Error("RateLimiter.reset needs a mutation or action ctx");
|
|
72
|
+
return ctx.runMutation(this.component.lib.reset, opts.key !== undefined ? { name, key: opts.key } : { name });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Action/HTTP-side check against the Cloudflare Rate Limiting binding the component asked for
|
|
77
|
+
* (`app.use(rateLimit, { config: { binding: { name, limit, period } } })`). Approximate and per Cloudflare
|
|
78
|
+
* location: use it to shed abusive traffic before it reaches a shard; use `RateLimiter` for exact quotas.
|
|
79
|
+
*/
|
|
80
|
+
export async function edgeLimit(ctx, binding, key) {
|
|
81
|
+
const b = ctx.env[binding];
|
|
82
|
+
if (!b || typeof b.limit !== "function") {
|
|
83
|
+
throw new EmberError("ValidationError", `edgeLimit: no Rate Limiting binding ${binding} (install rateLimit with { binding } and deploy)`);
|
|
84
|
+
}
|
|
85
|
+
const r = await b.limit({ key });
|
|
86
|
+
return { ok: !!r.success };
|
|
87
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,48 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@freecorps/ember-rate-limit",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Rate limiting component for Ember: token bucket and fixed window per key, stored in the shard and transactional inside mutations; optional Cloudflare Rate Limiting binding helper.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ember",
|
|
7
|
+
"convex",
|
|
8
|
+
"cloudflare",
|
|
9
|
+
"workers",
|
|
10
|
+
"component",
|
|
11
|
+
"rate-limit",
|
|
12
|
+
"rate-limiter",
|
|
13
|
+
"token-bucket"
|
|
14
|
+
],
|
|
15
|
+
"homepage": "https://github.com/freecorps/ember/tree/main/packages/rate-limit#readme",
|
|
16
|
+
"bugs": {
|
|
17
|
+
"url": "https://github.com/freecorps/ember/issues"
|
|
18
|
+
},
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "git+https://github.com/freecorps/ember.git",
|
|
22
|
+
"directory": "packages/rate-limit"
|
|
23
|
+
},
|
|
24
|
+
"license": "MIT",
|
|
25
|
+
"type": "module",
|
|
26
|
+
"sideEffects": false,
|
|
27
|
+
"main": "./dist/index.js",
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": "./dist/index.d.ts",
|
|
32
|
+
"default": "./dist/index.js"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"files": [
|
|
36
|
+
"dist"
|
|
37
|
+
],
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=22"
|
|
40
|
+
},
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"dependencies": {
|
|
45
|
+
"@freecorps/ember-server": "0.3.0",
|
|
46
|
+
"@freecorps/ember-values": "0.3.0"
|
|
47
|
+
}
|
|
48
|
+
}
|