@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 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
- export const send = mutation.use(dbRateLimit(config, "send", { key: (ctx) => ctx.auth.userId })).mutation(async ({ ctx }) => {
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
- export const login = mutation.mutation(async ({ ctx, args }) => {
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
- /** Sub-key derived from `ctx` (per-user/IP). Omit for a global limit. */
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 `retryAfter` in milliseconds — the runtime maps it to the
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
- /** Sub-key derived from `ctx` (per-user/IP). Omit for a global limit. */
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 `retryAfter` in milliseconds — the runtime maps it to the
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-BbhpusEn.mjs";import{default as l}from"./packem_shared/RateLimitError-BfvwU1oc.mjs";import{rateLimit as x}from"./packem_shared/rateLimit-DXZ0uxTf.mjs";import{ratelimitPlugin as c}from"./packem_shared/ratelimitPlugin-CFnDs9rj.mjs";import{RateLimiter as u}from"./packem_shared/RateLimiter-DGce8W6F.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
+ 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-DXZ0uxTf.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
+ 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-BfvwU1oc.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
+ 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-DXZ0uxTf.mjs";import{RateLimiter as m}from"./RateLimiter-DGce8W6F.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};
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.34",
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.28"
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};