@prexjs/rate-limit 0.0.0-stage → 1.0.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/lib/index.mjs ADDED
@@ -0,0 +1,7 @@
1
+ /*!
2
+ * @prexjs/rate-limit
3
+ * Copyright (c) 2026 Prexjs (https://prexjs.com)
4
+ * Copyright (c) 2026 Sorabh (https://Sorabh.org)
5
+ * MIT Licensed
6
+ */
7
+ import{isIPv6 as e,isIP as t}from"node:net";import{Address6 as r}from"@prexjs/ipaddress";import i from"@prexjs/debug";import{Buffer as s}from"node:buffer";import{createHash as n}from"node:crypto";const a=new r("::/96");function o(t,i=56){if(e(t)){const e=new r(t);if(e.isMapped4()||e.is4()&&e.isInSubnet(a))return e.to4().correctForm();if(i){return new r(`${t}/${i}`).networkForm()}}return t}class d{validations;windowMs;previous=new Map;current=new Map;interval;localKeys=!0;constructor(e){this.validations=e}init(e){this.windowMs=e.windowMs,this.validations?.windowMs(this.windowMs),this.interval&&clearInterval(this.interval),this.interval=setInterval(()=>{this.clearExpired()},this.windowMs),this.interval.unref?.()}async get(e){return this.current.get(e)??this.previous.get(e)}async increment(e){const t=this.getClient(e),r=Date.now();return t.resetTime.getTime()<=r&&this.resetClient(t,r),t.totalHits++,t}async decrement(e){const t=this.getClient(e);t.totalHits>0&&t.totalHits--}async resetKey(e){this.current.delete(e),this.previous.delete(e)}async resetAll(){this.current.clear(),this.previous.clear()}shutdown(){clearInterval(this.interval),this.resetAll()}resetClient(e,t=Date.now()){return e.totalHits=0,e.resetTime.setTime(t+this.windowMs),e}getClient(e){if(this.current.has(e))return this.current.get(e);let t;return this.previous.has(e)?(t=this.previous.get(e),this.previous.delete(e)):(t={totalHits:0,resetTime:new Date},this.resetClient(t)),this.current.set(e,t),t}clearExpired(){this.previous=this.current,this.current=new Map}}const c={warn(...e){console.warn(...e.reverse())},error(...e){console.error(...e.reverse())}},l=["draft-6","draft-7","draft-8"],u=(e,t)=>{let r;if(t){const e=Math.ceil((t.getTime()-Date.now())/1e3);r=Math.max(0,e)}else r=Math.ceil(e/1e3);return r},f=(e,t,r,i,a)=>{if(e.headersSent)return;const o=Math.ceil(r/1e3),d=u(r,t.resetTime),c=(e=>{const t=n("sha256");t.update(e);const r=t.digest("hex").slice(0,12);return s.from(r).toString("base64")})(a),l=`r=${t.remaining}; t=${d}`,f=`q=${t.limit}; w=${o}; pk=:${c}:`;e.append("RateLimit",`"${i}"; ${l}`),e.append("RateLimit-Policy",`"${i}"; ${f}`)};class p extends Error{name;code;help;constructor(e,t){const r=`https://rate-limit.github.io/${e}/`;super(`${t} See ${r} for more information.`),this.name=this.constructor.name,this.code=e,this.help=r}}class h extends p{}const m=new Set,w=new WeakMap,y={enabled:{default:!0},disable(){for(const e of Object.keys(this.enabled))this.enabled[e]=!1},ip(e){if(void 0===e)throw new p("ERR_ERL_UNDEFINED_IP_ADDRESS","An undefined 'request.ip' was detected. This might indicate a misconfiguration or the connection being destroyed prematurely.");if(!t(e))throw new p("ERR_ERL_INVALID_IP_ADDRESS",`An invalid 'request.ip' (${e}) was detected. Consider passing a custom 'keyGenerator' function to the rate limiter.`)},trustProxy(e){if(!0===e.app.get("trust proxy"))throw new p("ERR_ERL_PERMISSIVE_TRUST_PROXY","The @prexjs/core 'trust proxy' setting is true, which allows anyone to trivially bypass IP-based rate limiting.")},xForwardedForHeader(e){if(e.headers["x-forwarded-for"]&&!1===e.app.get("trust proxy"))throw new p("ERR_ERL_UNEXPECTED_X_FORWARDED_FOR","The 'X-Forwarded-For' header is set but the @prexjs/core 'trust proxy' setting is false (default). This could indicate a misconfiguration which would prevent rate-limit from accurately identifying users.")},forwardedHeader(e){if(e.headers.forwarded&&e.ip===e.socket?.remoteAddress)throw new p("ERR_ERL_FORWARDED_HEADER","The 'Forwarded' header (standardized X-Forwarded-For) is set but currently being ignored. Add a custom keyGenerator to use a value from this header.")},positiveHits(e){if("number"!=typeof e||e<1||e!==Math.round(e))throw new p("ERR_ERL_INVALID_HITS",`The totalHits value returned from the store must be a positive integer, got ${e}`)},unsharedStore(e){if(m.has(e)){const t=e?.localKeys?"":" (with a unique prefix)";throw new p("ERR_ERL_STORE_REUSE",`A Store instance must not be shared across multiple rate limiters. Create a new instance of ${e.constructor.name}${t} for each limiter instead.`)}m.add(e)},singleCount(e,t,r){let i=w.get(e);i||(i=new Map,w.set(e,i));const s=t.localKeys?t:t.constructor.name;let n=i.get(s);n||(n=[],i.set(s,n));const a=`${t.prefix??""}${r}`;if(n.includes(a))throw new p("ERR_ERL_DOUBLE_COUNT",`The hit count for ${r} was incremented more than once for a single request.`);n.push(a)},limit(e){if(0===e)throw new h("WRN_ERL_MAX_ZERO","Setting limit or max to 0 disables rate limiting in rate-limit v6 and older, but will cause all requests to be blocked in v7")},draftPolliHeaders(e){if(e)throw new h("WRN_ERL_DEPRECATED_DRAFT_POLLI_HEADERS","The draft_polli_ratelimit_headers configuration option is deprecated and has been removed in rate-limit v7, please set standardHeaders: 'draft-6' instead.")},onLimitReached(e){if(e)throw new h("WRN_ERL_DEPRECATED_ON_LIMIT_REACHED","The onLimitReached configuration option is deprecated and has been removed in rate-limit v7.")},headersDraftVersion(e){if("string"!=typeof e||!l.includes(e)){const e=l.join(", ");throw new p("ERR_ERL_HEADERS_UNSUPPORTED_DRAFT_VERSION",`standardHeaders: only the following versions of the IETF draft specification are supported: ${e}.`)}},headersResetTime(e){if(!e)throw new p("ERR_ERL_HEADERS_NO_RESET","standardHeaders: 'draft-7' requires a 'resetTime', but the store did not provide one. The 'windowMs' value will be used instead, which may cause clients to wait longer than necessary.")},knownOptions(e){if(!e)return;const t=Object.keys({windowMs:!0,limit:!0,message:!0,statusCode:!0,legacyHeaders:!0,standardHeaders:!0,identifier:!0,retryAfter:!0,requestPropertyName:!0,skipFailedRequests:!0,skipSuccessfulRequests:!0,keyGenerator:!0,ipv6Subnet:!0,handler:!0,skip:!0,requestWasSuccessful:!0,store:!0,validate:!0,headers:!0,max:!0,passOnStoreError:!0,logger:!0}).concat("draft_polli_ratelimit_headers","delayAfter","delayMs","maxDelayMs");for(const r of Object.keys(e))if(!t.includes(r))throw new p("ERR_ERL_UNKNOWN_OPTION",`Unexpected configuration option: ${r}`)},validationsConfig(){const e=Object.keys(this).filter(e=>!["enabled","disable"].includes(e));e.push("default");for(const t of Object.keys(this.enabled))if(!e.includes(t))throw new p("ERR_ERL_UNKNOWN_VALIDATION",`options.validate.${t} is not recognized. Supported validate options are: ${e.join(", ")}.`)},creationStack(e){const{stack:t}=new Error("rate-limit validation check (set options.validate.creationStack=false to disable)");if(t?.includes("Layer.handle [as handle_request]")||t?.includes("Layer.handleRequest")){if(!e.localKeys)throw new p("ERR_ERL_CREATED_IN_REQUEST_HANDLER","rate-limit instance should *usually* be created at app initialization, not when responding to a request.");throw new p("ERR_ERL_CREATED_IN_REQUEST_HANDLER","rate-limit instance should be created at app initialization, not when responding to a request.")}},ipv6Subnet(e){if(!1!==e&&(!Number.isInteger(e)||e<32||e>64))throw new p("ERR_ERL_IPV6_SUBNET",`Unexpected ipv6Subnet value: ${e}. Expected an integer between 32 and 64 (usually 48-64).`)},ipv6SubnetOrKeyGenerator(e){if(void 0!==e.ipv6Subnet&&e.keyGenerator)throw new p("ERR_ERL_IPV6SUBNET_OR_KEYGENERATOR","Incompatible options: the 'ipv6Subnet' option is ignored when a custom 'keyGenerator' function is also set.")},keyGeneratorIpFallback(e){if(!e)return;const t=e.toString();if((t.includes("req.ip")||t.includes("request.ip"))&&!t.includes("ipKeyGenerator"))throw new p("ERR_ERL_KEY_GEN_IPV6","Custom keyGenerator appears to use request IP without calling the ipKeyGenerator helper function for IPv6 addresses. This could allow IPv6 users to bypass limits.")},windowMs(e){const t=2**31-1;if("number"!=typeof e||Number.isNaN(e)||e<1||e>t)throw new p("ERR_ERL_WINDOW_MS",`Invalid windowMs value: ${e}${"number"!=typeof e?` (${typeof e})`:""}, must be a number between 1 and 2147483647 when using the default MemoryStore`)}};const R=(e,t)=>{let r;!function(e){if("object"!=typeof e||"function"!=typeof e.error||"function"!=typeof e.warn)throw new TypeError("Provided logger does not implement the Logger interface")}(t),r="boolean"==typeof e?{default:e}:{default:!0,...e};const i={enabled:r};for(const[e,s]of Object.entries(y))"function"==typeof s&&(i[e]=(...n)=>{if(r[e]??r.default){r[e]=!1;try{s.apply(i,n)}catch(e){e instanceof h?t.warn(e):t.error(e)}}});const s=Symbol.for("nodejs.util.inspect.custom");return s&&(i[s]=()=>i.enabled),i},g=e=>{if("function"!=typeof(t=e).incr||"function"==typeof t.increment)return e;var t;const r=e;return new class{async increment(e){return new Promise((t,i)=>{r.incr(e,(e,r,s)=>{e&&i(e),t({totalHits:r,resetTime:s})})})}async decrement(e){return r.decrement(e)}async resetKey(e){return r.resetKey(e)}async resetAll(){if("function"==typeof r.resetAll)return r.resetAll()}}},E=t=>{const r=(e=>{const t={};for(const r of Object.keys(e)){const i=r;void 0!==e[i]&&(t[i]=e[i])}return t})(t),i=t.logger??c,s=R(r?.validate??!0,i);s.validationsConfig(),s.knownOptions(t),s.draftPolliHeaders(r.draft_polli_ratelimit_headers),s.onLimitReached(r.onLimitReached),void 0!==r.ipv6Subnet&&"function"!=typeof r.ipv6Subnet&&s.ipv6Subnet(r.ipv6Subnet),s.keyGeneratorIpFallback(r.keyGenerator),s.ipv6SubnetOrKeyGenerator(r);let n=r.standardHeaders??!1;!0===n&&(n="draft-6");const a={windowMs:6e4,limit:t.max??5,message:"Too many requests, please try again later.",statusCode:429,legacyHeaders:t.headers??!0,identifier(e,t){let r="";const i=a.requestPropertyName,{limit:s}=e[i],n=a.windowMs/1e3,o=a.windowMs/6e4,d=a.windowMs/36e5,c=a.windowMs/864e5;return r=n<60?`${n}sec`:o<60?`${o}min`:d<24?`${d}hr${d>1?"s":""}`:`${c}day${c>1?"s":""}`,`${s}-in-${r}`},requestPropertyName:"rateLimit",skipFailedRequests:!1,skipSuccessfulRequests:!1,requestWasSuccessful:(e,t)=>t.statusCode<400,skip:(e,t)=>!1,async keyGenerator(t,r){s.ip(t.ip),s.trustProxy(t),s.xForwardedForHeader(t),s.forwardedHeader(t);const i=t.ip;let n=56;return e(i)&&(n="function"==typeof a.ipv6Subnet?await a.ipv6Subnet(t,r):a.ipv6Subnet,"function"==typeof a.ipv6Subnet&&s.ipv6Subnet(n)),o(i,n)},ipv6Subnet:56,async handler(e,t,r,i){t.status(a.statusCode);const s="function"==typeof a.message?await a.message(e,t):a.message;t.writableEnded||t.send(s)},passOnStoreError:!1,...r,standardHeaders:n,store:g(r.store??new d(s)),validations:s,logger:i};if("function"!=typeof a.store.increment||"function"!=typeof a.store.decrement||"function"!=typeof a.store.resetKey||void 0!==a.store.resetAll&&"function"!=typeof a.store.resetAll||void 0!==a.store.init&&"function"!=typeof a.store.init)throw new TypeError("An invalid store was passed. Please ensure that the store is a class that implements the `Store` interface.");return a},v=e=>{const t=E(e??{}),r=(e=>{const{validations:t,...r}=e;return{...r,validate:t.enabled}})(t),s=i("rate-limit");s("creating new rate limiter with %o",t.store.constructor.name);for(const[e,r]of Object.entries(t))s("set %s to %o",e,r);if(t.validations.creationStack(t.store),t.validations.unsharedStore(t.store),"function"==typeof t.store.init){s("executing init for store");try{const e=t.store.init(r);e instanceof Promise&&e.catch(e=>t.logger.error(e,"rate-limit: async error during store initialization."))}catch(e){t.logger.error(e,"rate-limit: error during store initialization.")}}const n=(a=async(e,i,n)=>{const a=t.skipFailedRequests&&new Promise(e=>i.once("close",e)),o=(t.skipFailedRequests||t.skipSuccessfulRequests)&&new Promise(e=>i.once("finish",e)),d=t.skipFailedRequests&&new Promise(e=>i.once("error",e));if(s("requested %o",e.originalUrl),s("request from ip %o",e.ip),await t.skip(e,i))return s("skipping request"),void n();const c=e,l=await t.keyGenerator(e,i);s("computed key %o",l),s("incrementing count");let p,h=0;try{const e=await t.store.increment(l);h=e.totalHits,p=e.resetTime}catch(e){if(t.passOnStoreError)return t.logger.error(e,"rate-limit: error from store, allowing request without rate-limiting."),void n();throw e}t.validations.positiveHits(h),t.validations.singleCount(e,t.store,l);const m="function"==typeof t.limit?t.limit(e,i):t.limit,w=await m;t.validations.limit(w);const y={limit:w,used:h,remaining:Math.max(w-h,0),resetTime:p,key:l};if(s.enabled)for(const[e,r]of Object.entries(y))s("set request.%s.%s to be %o",t.requestPropertyName,e,r);if(Object.defineProperty(y,"current",{configurable:!1,enumerable:!1,value:h}),c[t.requestPropertyName]=y,t.legacyHeaders&&!i.headersSent&&(s("set legacy headers"),((e,t)=>{e.headersSent||(e.setHeader("X-RateLimit-Limit",t.limit.toString()),e.setHeader("X-RateLimit-Remaining",t.remaining.toString()),t.resetTime instanceof Date&&(e.setHeader("Date",(new Date).toUTCString()),e.setHeader("X-RateLimit-Reset",Math.ceil(t.resetTime.getTime()/1e3).toString())))})(i,y)),t.standardHeaders&&!i.headersSent)switch(t.standardHeaders){case"draft-6":s("set ietf draft 6 headers"),((e,t,r)=>{if(e.headersSent)return;const i=Math.ceil(r/1e3),s=u(r,t.resetTime);e.setHeader("RateLimit-Policy",`${t.limit};w=${i}`),e.setHeader("RateLimit-Limit",t.limit.toString()),e.setHeader("RateLimit-Remaining",t.remaining.toString()),"number"==typeof s&&e.setHeader("RateLimit-Reset",s.toString())})(i,y,t.windowMs);break;case"draft-7":s("set ietf draft 7 headers"),t.validations.headersResetTime(y.resetTime),((e,t,r)=>{if(e.headersSent)return;const i=Math.ceil(r/1e3),s=u(r,t.resetTime);e.setHeader("RateLimit-Policy",`${t.limit};w=${i}`),e.setHeader("RateLimit",`limit=${t.limit}, remaining=${t.remaining}, reset=${s}`)})(i,y,t.windowMs);break;case"draft-8":{const r="function"==typeof t.identifier?t.identifier(e,i):t.identifier,n=await r;s("set ietf draft 8 headers"),s("set name to %o",n),t.validations.headersResetTime(y.resetTime),f(i,y,t.windowMs,n,l);break}default:t.validations.headersDraftVersion(t.standardHeaders)}if(t.skipFailedRequests||t.skipSuccessfulRequests){let r=!1;const n=async()=>{if(!r){if(p&&Date.now()>=p.getTime())return;s("decrementing count"),await t.store.decrement(l),r=!0}};t.skipFailedRequests&&(o&&o.then(async()=>{const r=await t.requestWasSuccessful(e,i);s("computed requestWasSuccessful as %o",r),r||await n()}),a&&a.then(async()=>{i.writableEnded||await n()}),d&&d.then(async()=>{await n()})),t.skipSuccessfulRequests&&o&&o.then(async()=>{const r=await t.requestWasSuccessful(e,i);s("computed requestWasSuccessful as %o",r),r&&await n()})}if(h>w){if(s("limit exceeded"),t.legacyHeaders||t.standardHeaders){s("set retry-after header");const r="function"==typeof t.retryAfter?t.retryAfter(e,i):t.retryAfter,n=await r;((e,t,r,i)=>{if(e.headersSent)return;const s=i??u(r,t.resetTime);e.setHeader("Retry-After",s.toString())})(i,y,t.windowMs,n)}t.handler(e,i,n,r)}else n()},async(e,t,r)=>{try{await Promise.resolve(a(e,t,r)).catch(r)}catch(e){r(e)}});var a;return n.resetKey=t.store.resetKey.bind(t.store),n.getKey="function"==typeof t.store.get?t.store.get.bind(t.store):()=>{throw new Error("The current store does not support the get/getKey method")},n},_=1e3,b=6e4,S=36e5,T=24*S;export{T as DAY,S as HOUR,b as MINUTE,d as MemoryStore,_ as SECOND,v as default,o as ipKeyGenerator,v as rateLimit};
package/package.json CHANGED
@@ -1,6 +1,67 @@
1
1
  {
2
2
  "name": "@prexjs/rate-limit",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Configurable rate limiting middleware for Node.js, Express, and Prex to protect endpoints from abuse and brute force.",
5
+ "authors": [
6
+ {
7
+ "name": "Prexjs",
8
+ "email": "hello@prexjs.com",
9
+ "url": "https://prexjs.com"
10
+ },
11
+ {
12
+ "name": "Sorabh",
13
+ "email": "hello@sorabh.org",
14
+ "url": "https://sorabh.org"
15
+ }
16
+ ],
17
+ "license": "MIT",
18
+ "type": "module",
19
+ "sideEffects": false,
20
+ "files": [
21
+ "lib",
22
+ "README.md",
23
+ "LICENSE"
24
+ ],
25
+ "main": "./lib/index.cjs",
26
+ "module": "./lib/index.mjs",
27
+ "types": "./lib/index.d.cts",
28
+ "exports": {
29
+ ".": {
30
+ "import": {
31
+ "types": "./lib/index.d.mts",
32
+ "default": "./lib/index.mjs"
33
+ },
34
+ "require": {
35
+ "types": "./lib/index.d.cts",
36
+ "default": "./lib/index.cjs"
37
+ }
38
+ },
39
+ "./package.json": "./package.json"
40
+ },
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/prexjs/prex.js.git",
44
+ "directory": "packages/rate-limit"
45
+ },
46
+ "homepage": "https://github.com/prexjs/prex.js/tree/main/packages/rate-limit#readme",
47
+ "bugs": {
48
+ "url": "https://github.com/prexjs/prex.js/issues"
49
+ },
50
+ "publishConfig": {
51
+ "access": "public"
52
+ },
53
+ "keywords": [
54
+ "prex",
55
+ "rate-limit",
56
+ "ratelimit",
57
+ "throttle",
58
+ "security",
59
+ "brute-force",
60
+ "middleware",
61
+ "express"
62
+ ],
63
+ "dependencies": {
64
+ "@prexjs/debug": "^1.0.0",
65
+ "@prexjs/ipaddress": "^1.0.0"
66
+ }
67
+ }