@lunora/ratelimit 1.0.0-alpha.34 → 1.0.0-alpha.36
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/README.md +11 -3
- package/dist/index.d.mts +22 -2
- package/dist/index.d.ts +22 -2
- package/dist/index.mjs +1 -1
- package/dist/packem_shared/{RateLimitError-BfvwU1oc.mjs → RateLimitError-DGY72lb1.mjs} +1 -1
- package/dist/packem_shared/{RateLimiter-DGce8W6F.mjs → RateLimiter-Dju-S2P9.mjs} +1 -1
- package/dist/packem_shared/{dbRateLimit-BbhpusEn.mjs → dbRateLimit-CULRmEeZ.mjs} +1 -1
- package/dist/packem_shared/rateLimit-O1N476pF.mjs +1 -0
- package/package.json +2 -2
- package/dist/packem_shared/rateLimit-DXZ0uxTf.mjs +0 -1
package/README.md
CHANGED
|
@@ -76,17 +76,25 @@ const config = {
|
|
|
76
76
|
// enforcing the configured rate the moment the DO instance is sharded,
|
|
77
77
|
// replicated, or recreated.
|
|
78
78
|
// As procedure middleware — throws a structural LunoraError (429/403) on rejection.
|
|
79
|
-
|
|
79
|
+
// `key` must resolve to a string — a resolver returning `undefined` throws
|
|
80
|
+
// rather than silently sharing one bucket across every keyless caller.
|
|
81
|
+
export const send = mutation.use(dbRateLimit(config, "send", { key: (ctx) => ctx.auth.userId ?? "anonymous" })).mutation(async ({ ctx }) => {
|
|
80
82
|
// …
|
|
81
83
|
});
|
|
82
84
|
```
|
|
83
85
|
|
|
84
|
-
Or call the limiter directly, inside a mutation/action so `ctx.db` is available
|
|
86
|
+
Or call the limiter directly, inside a mutation/action so `ctx.db` is available.
|
|
87
|
+
A login limiter belongs in an **action**: a mutation's `ctx.db` writes ride its
|
|
88
|
+
storage transaction, so a handler that throws (a wrong password) rolls the
|
|
89
|
+
consumed unit back and every failed attempt is free. An action's writes commit
|
|
90
|
+
on their own, so the charge stays whether or not the handler throws.
|
|
85
91
|
|
|
86
92
|
```ts
|
|
87
93
|
import { RateLimiter, RateLimitError, createDbStore } from "@lunora/ratelimit";
|
|
88
94
|
|
|
89
|
-
|
|
95
|
+
import { action } from "./_generated/server";
|
|
96
|
+
|
|
97
|
+
export const login = action.action(async ({ ctx, args }) => {
|
|
90
98
|
const limiter = new RateLimiter({ config, store: createDbStore({ db: ctx.db }) });
|
|
91
99
|
const status = await limiter.limit("login", { key: args.email });
|
|
92
100
|
|
package/dist/index.d.mts
CHANGED
|
@@ -203,7 +203,15 @@ interface RateLimitMiddlewareOptions<Context> {
|
|
|
203
203
|
* — note that a failing limiter then permits every request through.
|
|
204
204
|
*/
|
|
205
205
|
failOpen?: boolean;
|
|
206
|
-
/**
|
|
206
|
+
/**
|
|
207
|
+
* Sub-key derived from `ctx` (per-user/IP). Omit for a global limit.
|
|
208
|
+
*
|
|
209
|
+
* A resolver that returns `undefined` is a config bug, not a global limit:
|
|
210
|
+
* the middleware throws `INTERNAL` rather than silently pooling every
|
|
211
|
+
* keyless caller (e.g. every anonymous user) into one shared bucket. Fold
|
|
212
|
+
* the absent case yourself — `ctx.auth.userId ?? "anonymous"` — so the
|
|
213
|
+
* shared bucket is a visible choice.
|
|
214
|
+
*/
|
|
207
215
|
key?: (context: Context) => string | undefined;
|
|
208
216
|
/** Override the error message thrown on rejection. */
|
|
209
217
|
message?: string;
|
|
@@ -212,7 +220,7 @@ interface RateLimitMiddlewareOptions<Context> {
|
|
|
212
220
|
* Procedure middleware that enforces a named rate limit before the handler
|
|
213
221
|
* runs. Attach it with `.use()`. On rejection it throws a structural
|
|
214
222
|
* `LunoraError` (`TOO_MANY_REQUESTS`/429, or `FORBIDDEN`/403 for deny-list
|
|
215
|
-
* hits) carrying `
|
|
223
|
+
* hits) carrying `data.retryAfterMs` — the runtime maps it to the
|
|
216
224
|
* matching RPC/HTTP status without any import of `@lunora/server` at runtime.
|
|
217
225
|
*
|
|
218
226
|
* **Failure policy:** if resolving or invoking the limiter throws for a genuine
|
|
@@ -337,6 +345,13 @@ interface ReadOnlyDatabaseStoreOptions extends DatabaseStoreLocation {
|
|
|
337
345
|
*
|
|
338
346
|
* Each operation is a read-then-write; inside a mutation/action that pair runs
|
|
339
347
|
* under the DO's input gate, so it is atomic against concurrent calls.
|
|
348
|
+
*
|
|
349
|
+
* **Consumption commits with the procedure.** A mutation's `ctx.db` writes ride
|
|
350
|
+
* its storage transaction, so a handler that throws after `limit()` rolls the
|
|
351
|
+
* consumed unit back with everything else — inside a mutation this store counts
|
|
352
|
+
* successful calls, not attempts. To charge every attempt (a login limiter),
|
|
353
|
+
* consume from an action, where each write commits on its own, or return a
|
|
354
|
+
* failure value from the mutation instead of throwing.
|
|
340
355
|
*/
|
|
341
356
|
declare const createDatabaseStore: (options: DatabaseStoreOptions) => RateLimitStore;
|
|
342
357
|
/**
|
|
@@ -371,6 +386,11 @@ declare const createReadOnlyDatabaseStore: (options: ReadOnlyDatabaseStoreOption
|
|
|
371
386
|
* {@link rateLimit} unchanged. When `config` is precisely typed, `name`
|
|
372
387
|
* autocompletes to its declared limit names.
|
|
373
388
|
*
|
|
389
|
+
* On a mutation the consumed unit commits with the handler: a handler that
|
|
390
|
+
* throws rolls it back, so a failed call costs nothing. Attach it to an action
|
|
391
|
+
* (whose writes commit independently) when failed attempts must count — see
|
|
392
|
+
* {@link createDatabaseStore}.
|
|
393
|
+
*
|
|
374
394
|
* Re-exported as `dbRateLimit` from the package root.
|
|
375
395
|
*
|
|
376
396
|
* ```ts
|
package/dist/index.d.ts
CHANGED
|
@@ -203,7 +203,15 @@ interface RateLimitMiddlewareOptions<Context> {
|
|
|
203
203
|
* — note that a failing limiter then permits every request through.
|
|
204
204
|
*/
|
|
205
205
|
failOpen?: boolean;
|
|
206
|
-
/**
|
|
206
|
+
/**
|
|
207
|
+
* Sub-key derived from `ctx` (per-user/IP). Omit for a global limit.
|
|
208
|
+
*
|
|
209
|
+
* A resolver that returns `undefined` is a config bug, not a global limit:
|
|
210
|
+
* the middleware throws `INTERNAL` rather than silently pooling every
|
|
211
|
+
* keyless caller (e.g. every anonymous user) into one shared bucket. Fold
|
|
212
|
+
* the absent case yourself — `ctx.auth.userId ?? "anonymous"` — so the
|
|
213
|
+
* shared bucket is a visible choice.
|
|
214
|
+
*/
|
|
207
215
|
key?: (context: Context) => string | undefined;
|
|
208
216
|
/** Override the error message thrown on rejection. */
|
|
209
217
|
message?: string;
|
|
@@ -212,7 +220,7 @@ interface RateLimitMiddlewareOptions<Context> {
|
|
|
212
220
|
* Procedure middleware that enforces a named rate limit before the handler
|
|
213
221
|
* runs. Attach it with `.use()`. On rejection it throws a structural
|
|
214
222
|
* `LunoraError` (`TOO_MANY_REQUESTS`/429, or `FORBIDDEN`/403 for deny-list
|
|
215
|
-
* hits) carrying `
|
|
223
|
+
* hits) carrying `data.retryAfterMs` — the runtime maps it to the
|
|
216
224
|
* matching RPC/HTTP status without any import of `@lunora/server` at runtime.
|
|
217
225
|
*
|
|
218
226
|
* **Failure policy:** if resolving or invoking the limiter throws for a genuine
|
|
@@ -337,6 +345,13 @@ interface ReadOnlyDatabaseStoreOptions extends DatabaseStoreLocation {
|
|
|
337
345
|
*
|
|
338
346
|
* Each operation is a read-then-write; inside a mutation/action that pair runs
|
|
339
347
|
* under the DO's input gate, so it is atomic against concurrent calls.
|
|
348
|
+
*
|
|
349
|
+
* **Consumption commits with the procedure.** A mutation's `ctx.db` writes ride
|
|
350
|
+
* its storage transaction, so a handler that throws after `limit()` rolls the
|
|
351
|
+
* consumed unit back with everything else — inside a mutation this store counts
|
|
352
|
+
* successful calls, not attempts. To charge every attempt (a login limiter),
|
|
353
|
+
* consume from an action, where each write commits on its own, or return a
|
|
354
|
+
* failure value from the mutation instead of throwing.
|
|
340
355
|
*/
|
|
341
356
|
declare const createDatabaseStore: (options: DatabaseStoreOptions) => RateLimitStore;
|
|
342
357
|
/**
|
|
@@ -371,6 +386,11 @@ declare const createReadOnlyDatabaseStore: (options: ReadOnlyDatabaseStoreOption
|
|
|
371
386
|
* {@link rateLimit} unchanged. When `config` is precisely typed, `name`
|
|
372
387
|
* autocompletes to its declared limit names.
|
|
373
388
|
*
|
|
389
|
+
* On a mutation the consumed unit commits with the handler: a handler that
|
|
390
|
+
* throws rolls it back, so a failed call costs nothing. Attach it to an action
|
|
391
|
+
* (whose writes commit independently) when failed attempts must count — see
|
|
392
|
+
* {@link createDatabaseStore}.
|
|
393
|
+
*
|
|
374
394
|
* Re-exported as `dbRateLimit` from the package root.
|
|
375
395
|
*
|
|
376
396
|
* ```ts
|
package/dist/index.mjs
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
import{availableAt as o,evaluate as a}from"./packem_shared/availableAt-DmUh2qeW.mjs";import{default as i}from"./packem_shared/dbRateLimit-
|
|
1
|
+
import{availableAt as o,evaluate as a}from"./packem_shared/availableAt-DmUh2qeW.mjs";import{default as i}from"./packem_shared/dbRateLimit-CULRmEeZ.mjs";import{default as l}from"./packem_shared/RateLimitError-DGY72lb1.mjs";import{rateLimit as x}from"./packem_shared/rateLimit-O1N476pF.mjs";import{ratelimitPlugin as c}from"./packem_shared/ratelimitPlugin-CFnDs9rj.mjs";import{RateLimiter as u}from"./packem_shared/RateLimiter-Dju-S2P9.mjs";import{createDbStore as b,createMemoryStore as n,createReadOnlyDbStore as L,createSqlStore as s}from"./packem_shared/createDbStore-ve7GsO2i.mjs";import{tokenBudget as v}from"./packem_shared/tokenBudget-DVppKf-f.mjs";const e="0.0.0";export{l as RateLimitError,u as RateLimiter,e as VERSION,o as availableAt,b as createDbStore,n as createMemoryStore,L as createReadOnlyDbStore,s as createSqlStore,i as dbRateLimit,a as evaluate,x as rateLimit,c as ratelimitPlugin,v as tokenBudget};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{LunoraError as n}from"@lunora/errors";import{STATUS_BY_REASON as a}from"./rateLimit-
|
|
1
|
+
import{LunoraError as n}from"@lunora/errors";import{STATUS_BY_REASON as a}from"./rateLimit-O1N476pF.mjs";const s=r=>r.reason==="deny"?"request denied (deny list)":Number.isFinite(r.retryAfter)?`rate limit exceeded; retry after ${String(Math.ceil(r.retryAfter))}ms`:"rate limit exceeded";class c extends n{reason;retryAfter;constructor(e,t){const{code:i,status:o}=a[e.reason??"rate"];super(i,t??s(e),{name:"RateLimitError",status:o}),this.reason=e.reason,this.retryAfter=e.retryAfter}}export{c as default};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{LunoraError as n}from"@lunora/errors";import{availableAt as v,evaluate as p}from"./availableAt-DmUh2qeW.mjs";import u from"./RateLimitError-
|
|
1
|
+
import{LunoraError as n}from"@lunora/errors";import{availableAt as v,evaluate as p}from"./availableAt-DmUh2qeW.mjs";import u from"./RateLimitError-DGY72lb1.mjs";import{createMemoryStore as N}from"./createDbStore-ve7GsO2i.mjs";const f=(o,e)=>e===void 0?encodeURIComponent(o):`${encodeURIComponent(o)}:${encodeURIComponent(e)}`,g=(o,e)=>{let t=0;for(let r=0;r<o.length;r+=1)t=t*31+o.charCodeAt(r)|0;return Math.abs(t)%e},I="@lunora/ratelimit: new RateLimiter() was built with no explicit `store`, so it falls back to `createMemoryStore()` — an in-process Map. That Map is durable and correctly shared only for calls that land on the same Durable Object instance; it is NOT shared across `.shardBy(...)` shards or `.global()` replicas, and it resets when the DO instance is evicted/restarted. For a limit that must hold across any of those, pass a durable `store` (`createDbStore` or `createSqlStore`) instead. Pass `store: createMemoryStore()` explicitly once you've confirmed the in-memory default is correct here, to silence this warning.",y=(o,e)=>e>1?{...o,capacity:(o.capacity??o.rate)/e,rate:o.rate/e}:o,L=(o,e,t)=>{const r=f(o,e);return t>1?Array.from({length:t},(i,s)=>`${r}#${String(s)}`):[r]},w=(o,e,t)=>{const r=f(o,e);return t>1?`${r}#${String(g(r,t))}`:r};class z{config;denyList;normalize;now;store;constructor(e){this.config=e.config,this.denyList=new Set(e.denyList),this.normalize=e.normalize??(t=>t),this.now=e.now??Date.now,e.store===void 0&&console.warn(I),this.store=e.store??N();for(const[t,r]of Object.entries(this.config)){if(r.shards!==void 0&&(!Number.isInteger(r.shards)||r.shards<1))throw new n("INTERNAL",`rate limit "${t}": shards must be a positive integer`);if(!Number.isFinite(r.period)||r.period<=0)throw new n("INTERNAL",`rate limit "${t}": period must be a positive number`);if(!Number.isFinite(r.rate)||r.rate<=0)throw new n("INTERNAL",`rate limit "${t}": rate must be a positive number`);if(r.capacity!==void 0&&(!Number.isFinite(r.capacity)||r.capacity<0))throw new n("INTERNAL",`rate limit "${t}": capacity must be a non-negative number`)}}async check(e,t={}){return this.run(e,t,!1)}async getValue(e,t={}){const r=this.resolve(e),i=r.shards??1,s=this.now(),a=this.normalizeKey(t.key),h=w(e,a,i),c=v(y(r,i),await this.store.get(h),s);return{config:r,ts:c.ts,value:c.value}}async limit(e,t={}){return this.run(e,t,!0)}async reset(e,t={}){const r=this.resolve(e).shards??1,i=this.normalizeKey(t.key);await Promise.all(L(e,i,r).map(s=>Promise.resolve(this.store.delete(s))))}normalizeKey(e){return e===void 0?void 0:this.normalize(e)}resolve(e){const t=this.config[e];if(!t)throw new n("INTERNAL",`rate limit "${e}" is not configured`);return t}async run(e,t,r){const i=this.resolve(e),s=this.normalizeKey(t.key);if(s!==void 0&&(this.denyList.has(s)||this.denyList.has(t.key))){const d={ok:!1,reason:"deny",retryAfter:Number.POSITIVE_INFINITY};if(t.throws)throw new u(d);return d}const a=t.count??1;if(!Number.isInteger(a)||a<=0)throw new n("INTERNAL",`rate limit "${e}": count must be a positive integer`);const h=i.shards??1,c=w(e,s,h),b=await this.store.get(c),{status:l,value:m}=p(y(i,h),b,{consume:r,count:a,now:this.now(),reserve:t.reserve??!1});if(m!==void 0&&await this.store.set(c,m),!l.ok&&t.throws)throw new u(l);return l}}export{z as RateLimiter};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{rateLimit as o}from"./rateLimit-
|
|
1
|
+
import{rateLimit as o}from"./rateLimit-O1N476pF.mjs";import{RateLimiter as m}from"./RateLimiter-Dju-S2P9.mjs";import{createDbStore as i}from"./createDbStore-ve7GsO2i.mjs";const f=(e,r,t={})=>o(a=>new m({config:e,store:i({db:a.db,...t.store})}),r,t);export{f as default};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{isLunoraError as c,isInternalCode as l,LunoraError as n}from"@lunora/errors";const f={deny:{code:"FORBIDDEN",status:403},rate:{code:"TOO_MANY_REQUESTS",status:429}},v=(r,t,e)=>t==="deny"?`request denied for "${r}"`:e===void 0?`rate limit "${r}" exceeded`:`rate limit "${r}" exceeded; retry after ${String(e)}ms`,y=(r,t,e)=>{if(!e)return;const a=e(t);if(a===void 0)throw new n("INTERNAL",`@lunora/ratelimit: rateLimit("${r}") key resolver returned undefined; return a fallback such as "anonymous" instead`);return a},E=(r,t,e={})=>async({ctx:a,next:d})=>{let i;try{i=await(typeof r=="function"?await r(a):r).limit(t,{count:e.count,key:y(t,a,e.key)})}catch(o){if(c(o)&&l(o.code))throw o;if(console.error(`@lunora/ratelimit: rateLimit("${t}") threw; ${e.failOpen?"failing open":"failing closed"}`,o),e.failOpen)return d();throw new n("SERVICE_UNAVAILABLE",`rate limiter unavailable for "${t}"`,{cause:o,status:503})}if(!i.ok){const o=i.reason??"rate",u=f[o],s=Number.isFinite(i.retryAfter)?Math.ceil(i.retryAfter):void 0;throw new n(u.code,e.message??v(t,o,s),{status:u.status,data:s===void 0?void 0:{retryAfterMs:s}})}return d()};export{f as STATUS_BY_REASON,E as rateLimit};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lunora/ratelimit",
|
|
3
|
-
"version": "1.0.0-alpha.
|
|
3
|
+
"version": "1.0.0-alpha.36",
|
|
4
4
|
"description": "Rate limiting: token-bucket / fixed-window / sliding-window algorithms, deny list, sharding, pluggable stores, and procedure middleware",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"access": "public"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@lunora/errors": "1.0.0-alpha.
|
|
49
|
+
"@lunora/errors": "1.0.0-alpha.30"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
52
|
"@lunora/server": ">=1.0.0-alpha.24 <2.0.0-0",
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
import{isLunoraError as u,isInternalCode as l,LunoraError as c}from"@lunora/errors";const f={deny:{code:"FORBIDDEN",status:403},rate:{code:"TOO_MANY_REQUESTS",status:429}},y=(r,a,t)=>a==="deny"?`request denied for "${r}"`:t===void 0?`rate limit "${r}" exceeded`:`rate limit "${r}" exceeded; retry after ${String(t)}ms`,w=(r,a,t={})=>async({ctx:s,next:n})=>{let o;try{o=await(typeof r=="function"?await r(s):r).limit(a,{count:t.count,key:t.key?.(s)})}catch(e){if(u(e)&&l(e.code))throw e;if(console.error(`@lunora/ratelimit: rateLimit("${a}") threw; ${t.failOpen?"failing open":"failing closed"}`,e),t.failOpen)return n();throw new c("SERVICE_UNAVAILABLE",`rate limiter unavailable for "${a}"`,{cause:e,status:503})}if(!o.ok){const e=o.reason??"rate",d=f[e],i=Number.isFinite(o.retryAfter)?Math.ceil(o.retryAfter):void 0;throw new c(d.code,t.message??y(a,e,i),{status:d.status,data:i===void 0?void 0:{retryAfter:i}})}return n()};export{f as STATUS_BY_REASON,w as rateLimit};
|