@lunora/ratelimit 1.0.0-alpha.10 → 1.0.0-alpha.12

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/dist/index.mjs CHANGED
@@ -1,11 +1 @@
1
- export { availableAt, evaluate } from './packem_shared/availableAt-DQcuVfSA.mjs';
2
- export { default as dbRateLimit } from './packem_shared/dbRateLimit-WTU-e0r6.mjs';
3
- export { default as RateLimitError } from './packem_shared/RateLimitError-CiLy3DsZ.mjs';
4
- export { rateLimit } from './packem_shared/rateLimit-BBdG9GFo.mjs';
5
- export { ratelimitPlugin } from './packem_shared/ratelimitPlugin-D5_-KV1T.mjs';
6
- export { RateLimiter } from './packem_shared/RateLimiter-rDCxu_Nx.mjs';
7
- export { createDbStore, createMemoryStore, createSqlStore } from './packem_shared/createDbStore-L1kD1g1n.mjs';
8
-
9
- const VERSION = "0.0.0";
10
-
11
- export { VERSION };
1
+ import{availableAt as o,evaluate as a}from"./packem_shared/availableAt-DITZ7jdT.mjs";import{default as i}from"./packem_shared/dbRateLimit-D7voUQX3.mjs";import{default as l}from"./packem_shared/RateLimitError-xJ2Iv-i7.mjs";import{rateLimit as x}from"./packem_shared/rateLimit-DLcwK-84.mjs";import{ratelimitPlugin as c}from"./packem_shared/ratelimitPlugin-D1QHZF9b.mjs";import{RateLimiter as L}from"./packem_shared/RateLimiter-BcVZuxFA.mjs";import{createDbStore as b,createMemoryStore as d,createSqlStore as s}from"./packem_shared/createDbStore-VpnhCmRx.mjs";const e="0.0.0";export{l as RateLimitError,L as RateLimiter,e as VERSION,o as availableAt,b as createDbStore,d as createMemoryStore,s as createSqlStore,i as dbRateLimit,a as evaluate,x as rateLimit,c as ratelimitPlugin};
@@ -0,0 +1 @@
1
+ import{LunoraError as i}from"@lunora/errors";import{STATUS_BY_REASON as o}from"./rateLimit-DLcwK-84.mjs";const n=e=>e.reason==="deny"?"request denied (deny list)":Number.isFinite(e.retryAfter)?`rate limit exceeded; retry after ${String(Math.ceil(e.retryAfter))}ms`:"rate limit exceeded";class c extends i{reason;retryAfter;constructor(r,t){const{code:s,status:a}=o[r.reason??"rate"];super(s,t??n(r),{name:"RateLimitError",status:a}),this.reason=r.reason,this.retryAfter=r.retryAfter}}export{c as default};
@@ -0,0 +1 @@
1
+ import{LunoraError as n}from"@lunora/errors";import{availableAt as p,evaluate as N}from"./availableAt-DITZ7jdT.mjs";import d from"./RateLimitError-xJ2Iv-i7.mjs";import{createMemoryStore as g}from"./createDbStore-VpnhCmRx.mjs";const v=(r,t)=>t===void 0?encodeURIComponent(r):`${encodeURIComponent(r)}:${encodeURIComponent(t)}`,b=(r,t)=>{let e=0;for(let i=0;i<r.length;i+=1)e=e*31+r.charCodeAt(i)|0;return Math.abs(e)%t},w=(r,t)=>t>1?{...r,capacity:(r.capacity??r.rate)/t,rate:r.rate/t}:r,I=(r,t,e)=>{const i=v(r,t);return e>1?Array.from({length:e},(o,s)=>`${i}#${String(s)}`):[i]},f=(r,t,e)=>{const i=v(r,t);return e>1?`${i}#${String(b(i,e))}`:i};class R{config;denyList;normalize;now;store;constructor(t){this.config=t.config,this.denyList=new Set(t.denyList),this.normalize=t.normalize??(e=>e),this.now=t.now??Date.now,this.store=t.store??g();for(const[e,i]of Object.entries(this.config)){if(i.shards!==void 0&&(!Number.isInteger(i.shards)||i.shards<1))throw new n("INTERNAL",`rate limit "${e}": shards must be a positive integer`);if(!Number.isFinite(i.period)||i.period<=0)throw new n("INTERNAL",`rate limit "${e}": period must be a positive number`);if(!Number.isFinite(i.rate)||i.rate<=0)throw new n("INTERNAL",`rate limit "${e}": rate must be a positive number`);if(i.capacity!==void 0&&(!Number.isFinite(i.capacity)||i.capacity<0))throw new n("INTERNAL",`rate limit "${e}": capacity must be a non-negative number`)}}async check(t,e={}){return this.run(t,e,!1)}async getValue(t,e={}){const i=this.resolve(t),o=i.shards??1,s=this.now(),a=e.key===void 0?void 0:this.normalize(e.key),m=f(t,a,o),c=p(w(i,o),await this.store.get(m),s);return{config:i,ts:c.ts,value:c.value}}async limit(t,e={}){return this.run(t,e,!0)}async reset(t,e={}){const i=this.resolve(t).shards??1,o=e.key===void 0?void 0:this.normalize(e.key);await Promise.all(I(t,o,i).map(s=>Promise.resolve(this.store.delete(s))))}resolve(t){const e=this.config[t];if(!e)throw new n("INTERNAL",`rate limit "${t}" is not configured`);return e}async run(t,e,i){const o=this.resolve(t),s=e.key===void 0?void 0:this.normalize(e.key);if(s!==void 0&&(this.denyList.has(s)||this.denyList.has(e.key))){const l={ok:!1,reason:"deny",retryAfter:Number.POSITIVE_INFINITY};if(e.throws)throw new d(l);return l}const a=e.count??1;if(!Number.isInteger(a)||a<=0)throw new n("INTERNAL",`rate limit "${t}": count must be a positive integer`);const m=o.shards??1,c=f(t,s,m),y=await this.store.get(c),{status:h,value:u}=N(w(o,m),y,{consume:i,count:a,now:this.now(),reserve:e.reserve??!1});if(u!==void 0&&await this.store.set(c,u),!h.ok&&e.throws)throw new d(h);return h}}export{R as RateLimiter};
@@ -0,0 +1 @@
1
+ import{LunoraError as l}from"@lunora/errors";const v=r=>r.capacity??r.rate,p=(r,e,t)=>{const n=v(r),u=r.rate/r.period,o=e??{ts:t,value:n},a=Math.max(0,t-o.ts);return{available:Math.min(n,o.value+a*u),capacity:n,ratePerMs:u}},w=(r,e,t)=>{const n=r.start??0,u=n+Math.floor((t-n)/r.period)*r.period;if(!e||e.ts<u){let o=0;return e&&(e.value<0||r.capacity!==void 0)&&(o=e.value),{ts:u,value:Math.min(v(r),o+r.rate)}}return{ts:e.ts,value:e.value}},h=(r,e,t)=>{const n=r.start??0,u=n+Math.floor((t-n)/r.period)*r.period,o=t-u,a=(r.period-o)/r.period;let i=0,s=0;return e?.ts===u?(i=e.prev??0,s=e.value):e?.ts===u-r.period&&(i=e.value),{currentCount:s,elapsed:o,previousCount:i,weight:a,windowStart:u}},A=(r,e,t)=>{const{available:n,capacity:u,ratePerMs:o}=p(r,e,t.now);if(n>=t.count){const s={ts:t.now,value:n-t.count};return{status:{ok:!0,retryAfter:0},value:t.consume?s:void 0}}const a=t.count-n,i=Math.ceil(a/o);if(t.consume&&t.reserve&&t.count<=u)return{status:{ok:!0,retryAfter:i},value:{ts:t.now,value:n-t.count}};if(t.count>u)throw new l("INTERNAL",`@lunora/ratelimit: requested count ${String(t.count)} exceeds the limiter capacity ${String(u)}`);return{status:{ok:!1,reason:"rate",retryAfter:i},value:void 0}},M=(r,e,t)=>{const n=v(r),u=w(r,e,t.now);if(u.value>=t.count){const a={ts:u.ts,value:u.value-t.count};return{status:{ok:!0,retryAfter:0},value:t.consume?a:void 0}}const o=u.ts+r.period-t.now;if(t.consume&&t.reserve&&t.count<=n)return{status:{ok:!0,retryAfter:o},value:{ts:u.ts,value:u.value-t.count}};if(t.count>n)throw new l("INTERNAL",`@lunora/ratelimit: requested count ${String(t.count)} exceeds the limiter capacity ${String(n)}`);return{status:{ok:!1,reason:"rate",retryAfter:o},value:void 0}},g=(r,e,t)=>{const n=r.rate,{period:u}=r,{currentCount:o,elapsed:a,previousCount:i,weight:s,windowStart:m}=h(r,e,t.now),d=i*s+o+t.count<=n,f=()=>{const c=n-o-t.count;if(i>0&&c>=0)return Math.ceil(u-a-c*u/i);const k=n-t.count,y=o>0?Math.max(0,u-k*u/o):0;return Math.ceil(u-a+y)};if(d||t.consume&&t.reserve&&t.count<=n){const c={prev:i,ts:m,value:o+t.count};return{status:{ok:!0,retryAfter:d?0:f()},value:t.consume?c:void 0}}if(t.count>n)throw new l("INTERNAL",`@lunora/ratelimit: requested count ${String(t.count)} exceeds the limiter capacity ${String(n)}`);return{status:{ok:!1,reason:"rate",retryAfter:f()},value:void 0}},b=(r,e,t)=>{if(r.kind==="token bucket")return{ts:t,value:p(r,e,t).available};if(r.kind==="sliding window"){const{currentCount:n,previousCount:u,weight:o,windowStart:a}=h(r,e,t);return{ts:a,value:Math.max(0,r.rate-(u*o+n))}}return w(r,e,t)},x=(r,e,t)=>r.kind==="token bucket"?A(r,e,t):r.kind==="sliding window"?g(r,e,t):M(r,e,t);export{b as availableAt,x as evaluate};
@@ -0,0 +1 @@
1
+ const d=()=>{const a=new Map;return{delete:t=>{a.delete(t)},get:t=>a.get(t),set:(t,l)=>{a.set(t,l)}}},n=(a,t,...l)=>a.exec.call(a,t,...l).toArray(),p=a=>{const{sql:t}=a,l=a.table??"_lunora_rate_limits";return n(t,`CREATE TABLE IF NOT EXISTS "${l}" (k TEXT PRIMARY KEY, value REAL NOT NULL, ts INTEGER NOT NULL, prev REAL)`),{delete:u=>{n(t,`DELETE FROM "${l}" WHERE k = ?`,u)},get:u=>{const s=n(t,`SELECT value, ts, prev FROM "${l}" WHERE k = ?`,u)[0];if(!s)return;const v={ts:s.ts,value:s.value};return s.prev!==null&&(v.prev=s.prev),v},set:(u,s)=>{n(t,`INSERT INTO "${l}" (k, value, ts, prev) VALUES (?, ?, ?, ?) ON CONFLICT(k) DO UPDATE SET value = excluded.value, ts = excluded.ts, prev = excluded.prev`,u,s.value,s.ts,s.prev??null)}}},T=a=>{const{db:t}=a,l=a.table??"rateLimits",u=a.index??"by_key",s=a.keyField??"key",v=new Map,o=async r=>{const e=await t.query(l).withIndex(u,i=>i.eq(s,r)).first();return v.set(r,e?e._id:void 0),e},E=async r=>(v.has(r)||await o(r),v.get(r));return{delete:async r=>{const e=await E(r);e!==void 0&&await t.delete(e),v.delete(r)},get:async r=>{const e=await o(r);if(!e)return;const i={ts:e.ts,value:e.value};return e.prev!==null&&e.prev!==void 0&&(i.prev=e.prev),i},set:async(r,e)=>{const i=await E(r),c={[s]:r,ts:e.ts,value:e.value};e.prev!==void 0&&(c.prev=e.prev),i===void 0?v.set(r,await t.insert(l,c)):await t.patch(i,c)}}};export{T as createDbStore,d as createMemoryStore,p as createSqlStore};
@@ -0,0 +1 @@
1
+ import{rateLimit as a}from"./rateLimit-DLcwK-84.mjs";import{RateLimiter as i}from"./RateLimiter-BcVZuxFA.mjs";import{createDbStore as m}from"./createDbStore-VpnhCmRx.mjs";const b=(t,o,r={})=>a(e=>new i({config:t,store:m({db:e.db,...r.store})}),o,r);export{b as default};
@@ -0,0 +1 @@
1
+ import{isLunoraError as f,isInternalCode as l,LunoraError as c}from"@lunora/errors";const u={deny:{code:"FORBIDDEN",status:403},rate:{code:"TOO_MANY_REQUESTS",status:429}},m=(r,a,e)=>a==="deny"?`request denied for "${r}"`:e===void 0?`rate limit "${r}" exceeded`:`rate limit "${r}" exceeded; retry after ${String(e)}ms`,A=(r,a,e={})=>async({ctx:n,next:s})=>{let i;try{i=await(typeof r=="function"?await r(n):r).limit(a,{count:e.count,key:e.key?.(n)})}catch(t){if(f(t)&&l(t.code))throw t;if(console.error(`@lunora/ratelimit: rateLimit("${a}") threw; ${e.failOpen?"failing open":"failing closed"}`,t),e.failOpen)return s();throw new c("SERVICE_UNAVAILABLE",`rate limiter unavailable for "${a}"`,{cause:t,status:503})}if(!i.ok){const t=i.reason??"rate",d=u[t],o=Number.isFinite(i.retryAfter)?Math.ceil(i.retryAfter):void 0;throw new c(d.code,e.message??m(a,t,o),{status:d.status,data:o===void 0?void 0:{retryAfter:o}})}return s()};export{u as STATUS_BY_REASON,A as rateLimit};
@@ -0,0 +1 @@
1
+ const r=t=>({key:"ratelimit",middleware:async({ctx:i,next:a})=>{const n=typeof t=="function"?await t(i):t,e=i.api??{};return a({ctx:{api:{...e,ratelimit:n}}})}});export{r as ratelimitPlugin};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lunora/ratelimit",
3
- "version": "1.0.0-alpha.10",
3
+ "version": "1.0.0-alpha.12",
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.8"
49
+ "@lunora/errors": "1.0.0-alpha.9"
50
50
  },
51
51
  "engines": {
52
52
  "node": "^22.15.0 || >=24.11.0"
@@ -1,21 +0,0 @@
1
- import { LunoraError } from '@lunora/errors';
2
- import { STATUS_BY_REASON } from './rateLimit-BBdG9GFo.mjs';
3
-
4
- const describe = (status) => {
5
- if (status.reason === "deny") {
6
- return "request denied (deny list)";
7
- }
8
- return Number.isFinite(status.retryAfter) ? `rate limit exceeded; retry after ${String(Math.ceil(status.retryAfter))}ms` : "rate limit exceeded";
9
- };
10
- class RateLimitError extends LunoraError {
11
- reason;
12
- retryAfter;
13
- constructor(status, message) {
14
- const { code, status: httpStatus } = STATUS_BY_REASON[status.reason ?? "rate"];
15
- super(code, message ?? describe(status), { name: "RateLimitError", status: httpStatus });
16
- this.reason = status.reason;
17
- this.retryAfter = status.retryAfter;
18
- }
19
- }
20
-
21
- export { RateLimitError as default };
@@ -1,121 +0,0 @@
1
- import { LunoraError } from '@lunora/errors';
2
- import { availableAt, evaluate } from './availableAt-DQcuVfSA.mjs';
3
- import RateLimitError from './RateLimitError-CiLy3DsZ.mjs';
4
- import { createMemoryStore } from './createDbStore-L1kD1g1n.mjs';
5
-
6
- const storageKeyFor = (name, key) => key === void 0 ? encodeURIComponent(name) : `${encodeURIComponent(name)}:${encodeURIComponent(key)}`;
7
- const hashToShard = (storageKey, shards) => {
8
- let hash = 0;
9
- for (let index = 0; index < storageKey.length; index += 1) {
10
- hash = hash * 31 + storageKey.charCodeAt(index) | 0;
11
- }
12
- return Math.abs(hash) % shards;
13
- };
14
- const perShardConfig = (config, shards) => shards > 1 ? { ...config, capacity: (config.capacity ?? config.rate) / shards, rate: config.rate / shards } : config;
15
- const shardKeysFor = (name, key, shards) => {
16
- const base = storageKeyFor(name, key);
17
- return shards > 1 ? Array.from({ length: shards }, (_, shard) => `${base}#${String(shard)}`) : [base];
18
- };
19
- const routeStorageKey = (name, key, shards) => {
20
- const base = storageKeyFor(name, key);
21
- return shards > 1 ? `${base}#${String(hashToShard(base, shards))}` : base;
22
- };
23
- class RateLimiter {
24
- config;
25
- denyList;
26
- normalize;
27
- now;
28
- store;
29
- constructor(options) {
30
- this.config = options.config;
31
- this.denyList = new Set(options.denyList);
32
- this.normalize = options.normalize ?? ((key) => key);
33
- this.now = options.now ?? Date.now;
34
- this.store = options.store ?? createMemoryStore();
35
- for (const [name, config] of Object.entries(this.config)) {
36
- if (config.shards !== void 0 && (!Number.isInteger(config.shards) || config.shards < 1)) {
37
- throw new LunoraError("INTERNAL", `rate limit "${name}": shards must be a positive integer`);
38
- }
39
- if (!Number.isFinite(config.period) || config.period <= 0) {
40
- throw new LunoraError("INTERNAL", `rate limit "${name}": period must be a positive number`);
41
- }
42
- if (!Number.isFinite(config.rate) || config.rate <= 0) {
43
- throw new LunoraError("INTERNAL", `rate limit "${name}": rate must be a positive number`);
44
- }
45
- if (config.capacity !== void 0 && (!Number.isFinite(config.capacity) || config.capacity < 0)) {
46
- throw new LunoraError("INTERNAL", `rate limit "${name}": capacity must be a non-negative number`);
47
- }
48
- }
49
- }
50
- /** Peek at whether a request would be permitted without consuming. */
51
- async check(name, args = {}) {
52
- return this.run(name, args, false);
53
- }
54
- /**
55
- * Read the current config and the units admittable right now for a
56
- * `(name, key)` pair. The value is projected forward to the current clock
57
- * (token-bucket refill, fixed-window rollover, sliding-window decay), not
58
- * the last persisted figure. For a sharded limit it reads only the single
59
- * shard `limit()`/`run()` would route this key to — the sibling shards are
60
- * never touched by this key, so summing them would over-report.
61
- */
62
- async getValue(name, args = {}) {
63
- const config = this.resolve(name);
64
- const shards = config.shards ?? 1;
65
- const now = this.now();
66
- const normalizedKey = args.key === void 0 ? void 0 : this.normalize(args.key);
67
- const storageKey = routeStorageKey(name, normalizedKey, shards);
68
- const current = availableAt(perShardConfig(config, shards), await this.store.get(storageKey), now);
69
- return { config, ts: current.ts, value: current.value };
70
- }
71
- /** Consume capacity. Returns the outcome, or throws when `args.throws` is set. */
72
- async limit(name, args = {}) {
73
- return this.run(name, args, true);
74
- }
75
- /** Clear accounting for a `(name, key)` pair (e.g. on successful login). */
76
- async reset(name, args = {}) {
77
- const shards = this.resolve(name).shards ?? 1;
78
- const normalizedKey = args.key === void 0 ? void 0 : this.normalize(args.key);
79
- await Promise.all(shardKeysFor(name, normalizedKey, shards).map((storageKey) => Promise.resolve(this.store.delete(storageKey))));
80
- }
81
- resolve(name) {
82
- const config = this.config[name];
83
- if (!config) {
84
- throw new LunoraError("INTERNAL", `rate limit "${name}" is not configured`);
85
- }
86
- return config;
87
- }
88
- async run(name, args, consume) {
89
- const config = this.resolve(name);
90
- const normalizedKey = args.key === void 0 ? void 0 : this.normalize(args.key);
91
- if (normalizedKey !== void 0 && (this.denyList.has(normalizedKey) || this.denyList.has(args.key))) {
92
- const status2 = { ok: false, reason: "deny", retryAfter: Number.POSITIVE_INFINITY };
93
- if (args.throws) {
94
- throw new RateLimitError(status2);
95
- }
96
- return status2;
97
- }
98
- const count = args.count ?? 1;
99
- if (!Number.isInteger(count) || count <= 0) {
100
- throw new LunoraError("INTERNAL", `rate limit "${name}": count must be a positive integer`);
101
- }
102
- const shards = config.shards ?? 1;
103
- const storageKey = routeStorageKey(name, normalizedKey, shards);
104
- const prior = await this.store.get(storageKey);
105
- const { status, value } = evaluate(perShardConfig(config, shards), prior, {
106
- consume,
107
- count,
108
- now: this.now(),
109
- reserve: args.reserve ?? false
110
- });
111
- if (value !== void 0) {
112
- await this.store.set(storageKey, value);
113
- }
114
- if (!status.ok && args.throws) {
115
- throw new RateLimitError(status);
116
- }
117
- return status;
118
- }
119
- }
120
-
121
- export { RateLimiter };
@@ -1,114 +0,0 @@
1
- import { LunoraError } from '@lunora/errors';
2
-
3
- const capacityOf = (config) => config.capacity ?? config.rate;
4
- const projectTokenBucket = (config, prior, now) => {
5
- const capacity = capacityOf(config);
6
- const ratePerMs = config.rate / config.period;
7
- const base = prior ?? { ts: now, value: capacity };
8
- const elapsed = Math.max(0, now - base.ts);
9
- return { available: Math.min(capacity, base.value + elapsed * ratePerMs), capacity, ratePerMs };
10
- };
11
- const projectFixedWindow = (config, prior, now) => {
12
- const start = config.start ?? 0;
13
- const windowStart = start + Math.floor((now - start) / config.period) * config.period;
14
- if (!prior || prior.ts < windowStart) {
15
- let carry = 0;
16
- if (prior && (prior.value < 0 || config.capacity !== void 0)) {
17
- carry = prior.value;
18
- }
19
- return { ts: windowStart, value: Math.min(capacityOf(config), carry + config.rate) };
20
- }
21
- return { ts: prior.ts, value: prior.value };
22
- };
23
- const projectSlidingWindow = (config, prior, now) => {
24
- const start = config.start ?? 0;
25
- const windowStart = start + Math.floor((now - start) / config.period) * config.period;
26
- const elapsed = now - windowStart;
27
- const weight = (config.period - elapsed) / config.period;
28
- let previousCount = 0;
29
- let currentCount = 0;
30
- if (prior?.ts === windowStart) {
31
- previousCount = prior.prev ?? 0;
32
- currentCount = prior.value;
33
- } else if (prior?.ts === windowStart - config.period) {
34
- previousCount = prior.value;
35
- }
36
- return { currentCount, elapsed, previousCount, weight, windowStart };
37
- };
38
- const tokenBucket = (config, prior, options) => {
39
- const { available, capacity, ratePerMs } = projectTokenBucket(config, prior, options.now);
40
- if (available >= options.count) {
41
- const value = { ts: options.now, value: available - options.count };
42
- return { status: { ok: true, retryAfter: 0 }, value: options.consume ? value : void 0 };
43
- }
44
- const deficit = options.count - available;
45
- const retryAfter = Math.ceil(deficit / ratePerMs);
46
- if (options.consume && options.reserve && options.count <= capacity) {
47
- return { status: { ok: true, retryAfter }, value: { ts: options.now, value: available - options.count } };
48
- }
49
- if (options.count > capacity) {
50
- throw new LunoraError("INTERNAL", `@lunora/ratelimit: requested count ${String(options.count)} exceeds the limiter capacity ${String(capacity)}`);
51
- }
52
- return { status: { ok: false, reason: "rate", retryAfter }, value: void 0 };
53
- };
54
- const fixedWindow = (config, prior, options) => {
55
- const capacity = capacityOf(config);
56
- const base = projectFixedWindow(config, prior, options.now);
57
- if (base.value >= options.count) {
58
- const value = { ts: base.ts, value: base.value - options.count };
59
- return { status: { ok: true, retryAfter: 0 }, value: options.consume ? value : void 0 };
60
- }
61
- const retryAfter = base.ts + config.period - options.now;
62
- if (options.consume && options.reserve && options.count <= capacity) {
63
- return { status: { ok: true, retryAfter }, value: { ts: base.ts, value: base.value - options.count } };
64
- }
65
- if (options.count > capacity) {
66
- throw new LunoraError("INTERNAL", `@lunora/ratelimit: requested count ${String(options.count)} exceeds the limiter capacity ${String(capacity)}`);
67
- }
68
- return { status: { ok: false, reason: "rate", retryAfter }, value: void 0 };
69
- };
70
- const slidingWindow = (config, prior, options) => {
71
- const limit = config.rate;
72
- const { period } = config;
73
- const { currentCount, elapsed, previousCount, weight, windowStart } = projectSlidingWindow(config, prior, options.now);
74
- const estimated = previousCount * weight + currentCount;
75
- const admit = estimated + options.count <= limit;
76
- const retryAfter = () => {
77
- const headroomNow = limit - currentCount - options.count;
78
- if (previousCount > 0 && headroomNow >= 0) {
79
- return Math.ceil(period - elapsed - headroomNow * period / previousCount);
80
- }
81
- const headroomNext = limit - options.count;
82
- const intoNext = currentCount > 0 ? Math.max(0, period - headroomNext * period / currentCount) : 0;
83
- return Math.ceil(period - elapsed + intoNext);
84
- };
85
- if (admit || options.consume && options.reserve && options.count <= limit) {
86
- const value = { prev: previousCount, ts: windowStart, value: currentCount + options.count };
87
- return { status: { ok: true, retryAfter: admit ? 0 : retryAfter() }, value: options.consume ? value : void 0 };
88
- }
89
- if (options.count > limit) {
90
- throw new LunoraError("INTERNAL", `@lunora/ratelimit: requested count ${String(options.count)} exceeds the limiter capacity ${String(limit)}`);
91
- }
92
- return { status: { ok: false, reason: "rate", retryAfter: retryAfter() }, value: void 0 };
93
- };
94
- const availableAt = (config, prior, now) => {
95
- if (config.kind === "token bucket") {
96
- return { ts: now, value: projectTokenBucket(config, prior, now).available };
97
- }
98
- if (config.kind === "sliding window") {
99
- const { currentCount, previousCount, weight, windowStart } = projectSlidingWindow(config, prior, now);
100
- return { ts: windowStart, value: Math.max(0, config.rate - (previousCount * weight + currentCount)) };
101
- }
102
- return projectFixedWindow(config, prior, now);
103
- };
104
- const evaluate = (config, prior, options) => {
105
- if (config.kind === "token bucket") {
106
- return tokenBucket(config, prior, options);
107
- }
108
- if (config.kind === "sliding window") {
109
- return slidingWindow(config, prior, options);
110
- }
111
- return fixedWindow(config, prior, options);
112
- };
113
-
114
- export { availableAt, evaluate };
@@ -1,103 +0,0 @@
1
- const createMemoryStore = () => {
2
- const map = /* @__PURE__ */ new Map();
3
- return {
4
- delete: (storageKey) => {
5
- map.delete(storageKey);
6
- },
7
- get: (storageKey) => map.get(storageKey),
8
- set: (storageKey, value) => {
9
- map.set(storageKey, value);
10
- }
11
- };
12
- };
13
- const runSql = (sql, query, ...params) => {
14
- const runner = sql.exec;
15
- return runner.call(sql, query, ...params).toArray();
16
- };
17
- const createSqlStore = (options) => {
18
- const { sql } = options;
19
- const table = options.table ?? "_lunora_rate_limits";
20
- runSql(sql, `CREATE TABLE IF NOT EXISTS "${table}" (k TEXT PRIMARY KEY, value REAL NOT NULL, ts INTEGER NOT NULL, prev REAL)`);
21
- return {
22
- delete: (storageKey) => {
23
- runSql(sql, `DELETE FROM "${table}" WHERE k = ?`, storageKey);
24
- },
25
- get: (storageKey) => {
26
- const rows = runSql(sql, `SELECT value, ts, prev FROM "${table}" WHERE k = ?`, storageKey);
27
- const row = rows[0];
28
- if (!row) {
29
- return void 0;
30
- }
31
- const value = { ts: row.ts, value: row.value };
32
- if (row.prev !== null) {
33
- value.prev = row.prev;
34
- }
35
- return value;
36
- },
37
- set: (storageKey, value) => {
38
- runSql(
39
- sql,
40
- `INSERT INTO "${table}" (k, value, ts, prev) VALUES (?, ?, ?, ?) ON CONFLICT(k) DO UPDATE SET value = excluded.value, ts = excluded.ts, prev = excluded.prev`,
41
- storageKey,
42
- value.value,
43
- value.ts,
44
- // SQL bind: a missing `prev` must bind as SQL NULL, not undefined.
45
- // eslint-disable-next-line unicorn/no-null -- SQLite/D1 bind parameters require null for a NULL column
46
- value.prev ?? null
47
- );
48
- }
49
- };
50
- };
51
- const createDatabaseStore = (options) => {
52
- const { db } = options;
53
- const table = options.table ?? "rateLimits";
54
- const index = options.index ?? "by_key";
55
- const keyField = options.keyField ?? "key";
56
- const idCache = /* @__PURE__ */ new Map();
57
- const find = async (storageKey) => {
58
- const row = await db.query(table).withIndex(index, (q) => q.eq(keyField, storageKey)).first();
59
- idCache.set(storageKey, row ? row._id : void 0);
60
- return row;
61
- };
62
- const resolveId = async (storageKey) => {
63
- if (idCache.has(storageKey)) {
64
- return idCache.get(storageKey);
65
- }
66
- await find(storageKey);
67
- return idCache.get(storageKey);
68
- };
69
- return {
70
- delete: async (storageKey) => {
71
- const id = await resolveId(storageKey);
72
- if (id !== void 0) {
73
- await db.delete(id);
74
- }
75
- idCache.delete(storageKey);
76
- },
77
- get: async (storageKey) => {
78
- const row = await find(storageKey);
79
- if (!row) {
80
- return void 0;
81
- }
82
- const value = { ts: row.ts, value: row.value };
83
- if (row.prev !== null && row.prev !== void 0) {
84
- value.prev = row.prev;
85
- }
86
- return value;
87
- },
88
- set: async (storageKey, value) => {
89
- const id = await resolveId(storageKey);
90
- const document = { [keyField]: storageKey, ts: value.ts, value: value.value };
91
- if (value.prev !== void 0) {
92
- document.prev = value.prev;
93
- }
94
- if (id === void 0) {
95
- idCache.set(storageKey, await db.insert(table, document));
96
- } else {
97
- await db.patch(id, document);
98
- }
99
- }
100
- };
101
- };
102
-
103
- export { createDatabaseStore as createDbStore, createMemoryStore, createSqlStore };
@@ -1,7 +0,0 @@
1
- import { rateLimit } from './rateLimit-BBdG9GFo.mjs';
2
- import { RateLimiter } from './RateLimiter-rDCxu_Nx.mjs';
3
- import { createDbStore as createDatabaseStore } from './createDbStore-L1kD1g1n.mjs';
4
-
5
- const databaseRateLimit = (config, name, options = {}) => rateLimit((context) => new RateLimiter({ config, store: createDatabaseStore({ db: context.db, ...options.store }) }), name, options);
6
-
7
- export { databaseRateLimit as default };
@@ -1,40 +0,0 @@
1
- import { isLunoraError, isInternalCode, LunoraError } from '@lunora/errors';
2
-
3
- const STATUS_BY_REASON = {
4
- deny: { code: "FORBIDDEN", status: 403 },
5
- rate: { code: "TOO_MANY_REQUESTS", status: 429 }
6
- };
7
- const defaultMessage = (name, reason, retryAfter) => {
8
- if (reason === "deny") {
9
- return `request denied for "${name}"`;
10
- }
11
- return retryAfter === void 0 ? `rate limit "${name}" exceeded` : `rate limit "${name}" exceeded; retry after ${String(retryAfter)}ms`;
12
- };
13
- const rateLimit = (limiter, name, options = {}) => async ({ ctx, next }) => {
14
- let status;
15
- try {
16
- const resolved = typeof limiter === "function" ? await limiter(ctx) : limiter;
17
- status = await resolved.limit(name, { count: options.count, key: options.key?.(ctx) });
18
- } catch (error) {
19
- if (isLunoraError(error) && isInternalCode(error.code)) {
20
- throw error;
21
- }
22
- console.error(`@lunora/ratelimit: rateLimit("${name}") threw; ${options.failOpen ? "failing open" : "failing closed"}`, error);
23
- if (options.failOpen) {
24
- return next();
25
- }
26
- throw new LunoraError("SERVICE_UNAVAILABLE", `rate limiter unavailable for "${name}"`, { cause: error, status: 503 });
27
- }
28
- if (!status.ok) {
29
- const reason = status.reason ?? "rate";
30
- const mapped = STATUS_BY_REASON[reason];
31
- const retryAfter = Number.isFinite(status.retryAfter) ? Math.ceil(status.retryAfter) : void 0;
32
- throw new LunoraError(mapped.code, options.message ?? defaultMessage(name, reason, retryAfter), {
33
- status: mapped.status,
34
- data: retryAfter === void 0 ? void 0 : { retryAfter }
35
- });
36
- }
37
- return next();
38
- };
39
-
40
- export { STATUS_BY_REASON, rateLimit };
@@ -1,12 +0,0 @@
1
- const ratelimitPlugin = (limiter) => {
2
- return {
3
- key: "ratelimit",
4
- middleware: async ({ ctx, next }) => {
5
- const resolved = typeof limiter === "function" ? await limiter(ctx) : limiter;
6
- const existingApi = ctx.api ?? {};
7
- return next({ ctx: { api: { ...existingApi, ratelimit: resolved } } });
8
- }
9
- };
10
- };
11
-
12
- export { ratelimitPlugin };