UNPKG

next-expose

Version:

A fluent, type-safe API routing and middleware layer for the Next.js App Router.

55 lines (46 loc) 7.08 kB
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const f=require("./errors.cjs.js"),R=t=>async({req:e,next:i})=>{let n;try{n=await e.clone().json()}catch{throw new f.ValidationError(null,"Request body is not valid JSON.")}const s=await t.safeParseAsync(n);if(!s.success)throw new f.ValidationError({errors:s.error.flatten().fieldErrors},"Validation failed");const l={body:s.data};return i(l)},A=t=>async({req:e,next:i})=>{const n=new URL(e.url),s={};n.searchParams.forEach((o,c)=>{const a=s[c];a?Array.isArray(a)?a.push(o):s[c]=[a,o]:s[c]=o});const l=await t.safeParseAsync(s);if(!l.success)throw new f.ValidationError({errors:l.error.flatten().fieldErrors},"Invalid query parameters");const r={query:l.data};return i(r)},b=({req:t,next:e})=>{let i=null;const n=t.headers.get("x-forwarded-for");n&&(i=n.split(",")[0].trim());const s=t.headers.get("x-real-ip");s&&(i=s);const l=t.headers.get("cf-connecting-ip");return l&&(i=l),e({ip:i})};class p{buckets=new Map;cleanupInterval=null;constructor(e=6e4){this.startCleanup(e)}async consume(e,i){const n=i.limit??100,s=i.windowMs??6e4,l=Date.now();let r=this.buckets.get(e);if(!r)return r={tokens:n-1,lastRefill:l,expiresAt:l+s,limit:n},this.buckets.set(e,r),{allowed:!0,remaining:r.tokens,limit:n,resetAt:r.expiresAt,retryAfter:0};const c=(l-r.lastRefill)/s*r.limit;if(r.tokens=Math.min(r.limit,r.tokens+c),r.lastRefill=l,r.tokens>=1)return r.tokens-=1,r.expiresAt=l+s,{allowed:!0,remaining:Math.floor(r.tokens),limit:r.limit,resetAt:r.expiresAt,retryAfter:0};const a=s/r.limit,m=Math.ceil(a*(1-r.tokens));return{allowed:!1,remaining:0,limit:r.limit,resetAt:l+m,retryAfter:m}}async increment(e){const i=this.buckets.get(e);i&&(i.tokens=Math.min(i.limit,i.tokens+1))}async peek(e,i){const n=i.limit??100,s=i.windowMs??6e4,l=Date.now(),r=this.buckets.get(e);if(!r)return{remaining:n,limit:n,resetAt:l+s,retryAfter:0};const c=(l-r.lastRefill)/s*r.limit,a=Math.min(r.limit,r.tokens+c);return{remaining:Math.floor(a),limit:r.limit,resetAt:r.expiresAt,retryAfter:a<1?Math.ceil((1-a)*(s/r.limit)):0}}async reset(e){this.buckets.delete(e)}cleanup(){const e=Date.now();for(const[i,n]of this.buckets.entries())n.expiresAt<e-(n.expiresAt-n.lastRefill)&&this.buckets.delete(i)}startCleanup(e){this.cleanupInterval||(this.cleanupInterval=setInterval(()=>{this.cleanup()},e),this.cleanupInterval.unref&&this.cleanupInterval.unref())}destroy(){this.cleanupInterval&&(clearInterval(this.cleanupInterval),this.cleanupInterval=null),this.buckets.clear()}}const S=({req:t})=>{const e=t.headers.get("x-forwarded-for");if(e)return e.split(",")[0].trim();const i=t.headers.get("x-real-ip");if(i)return i;const n=t.headers.get("cf-connecting-ip");return n||"unknown"},_=t=>{const e=Math.ceil(t.retryAfter/1e3);return`Too many requests, please try again in ${e} second${e!==1?"s":""}`};function g(t,e,i,n){const s=new Headers(t.headers);return i&&(s.set("X-RateLimit-Limit",e.limit.toString()),s.set("X-RateLimit-Remaining",e.remaining.toString()),s.set("X-RateLimit-Reset",e.resetAt.toString())),n&&(s.set("X-Rate-Limit-Limit",e.limit.toString()),s.set("X-Rate-Limit-Remaining",e.remaining.toString()),s.set("X-Rate-Limit-Reset",e.resetAt.toString())),e.remaining===0&&s.set("Retry-After",Math.ceil(e.retryAfter/1e3).toString()),new Response(t.body,{status:t.status,statusText:t.statusText,headers:s})}const M=(t={})=>{if(t.skipFailedRequests&&t.skipSuccessfulRequests)throw new Error("`skipFailedRequests` and `skipSuccessfulRequests` cannot be used together.");const e=t.store??new p,i=t.keyGenerator??S,n=t.standardHeaders??!0,s=t.legacyHeaders??!1,l=t.skipSuccessfulRequests??!1,r=t.skipFailedRequests??!1;return async({req:o,ctx:c,next:a})=>{if(t.skip&&await t.skip({req:o,ctx:c}))return a();const m=await i({req:o,ctx:c}),d=await e.consume(m,t),u={remaining:d.remaining,limit:d.limit,resetAt:d.resetAt,retryAfter:d.retryAfter};if(!d.allowed){t.onRateLimit&&await t.onRateLimit(o,u);const k=typeof t.message=="function"?t.message(u):t.message??_(u);throw new f.TooManyRequestsError(k,u)}let h,w=null,y=!1;try{h=await a({rateLimit:u})}catch(k){throw w=k,k}finally{w?r&&(!f.isApiError(w)||w.statusCode!==429)&&(y=!0):l&&h.ok&&(y=!0),y&&e.increment&&(await e.increment(m),u.remaining=Math.min(u.limit,u.remaining+1))}return g(h,u,n,s)}};class v{constructor(e,i="rl:"){this.client=e,this.prefix=i}prefix;consumeScript=` local key = KEYS[1] local limit = tonumber(ARGV[1]) local window_ms = tonumber(ARGV[2]) local now = tonumber(ARGV[3]) local bucket = redis.call('HMGET', key, 'tokens', 'last_refill', 'limit') local tokens = tonumber(bucket[1]) local last_refill = tonumber(bucket[2]) local bucket_limit = tonumber(bucket[3]) if not tokens then -- First request: initialize bucket, saving the limit redis.call('HMSET', key, 'tokens', limit - 1, 'last_refill', now, 'limit', limit) redis.call('PEXPIRE', key, window_ms) return {1, limit - 1, now + window_ms, 0} end -- Use the limit from the bucket for consistency local current_limit = bucket_limit or limit -- Calculate token refill local time_since_refill = now - last_refill local tokens_to_add = (time_since_refill / window_ms) * current_limit tokens = math.min(current_limit, tokens + tokens_to_add) if tokens >= 1 then -- Consume token and allow request tokens = tokens - 1 redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now) redis.call('PEXPIRE', key, window_ms) return {1, math.floor(tokens), now + window_ms, 0} end -- Rate limited: calculate retry time local time_per_token = window_ms / current_limit local time_until_token = math.ceil(time_per_token * (1 - tokens)) return {0, 0, now + time_until_token, time_until_token} `;incrementScript=` local key = KEYS[1] -- Only increment if the key exists if redis.call('EXISTS', key) == 1 then local bucket = redis.call('HMGET', key, 'tokens', 'limit') local tokens = tonumber(bucket[1]) local limit = tonumber(bucket[2]) -- Increment only if below the limit to avoid over-filling if tokens and limit and tokens < limit then redis.call('HINCRBYFLOAT', key, 'tokens', 1) end end return 1 -- Return value is not used, just to signal completion `;async consume(e,i){const n=i.limit??100,s=i.windowMs??6e4,l=Date.now(),r=`${this.prefix}${e}`;try{const o=await this.client.eval(this.consumeScript,1,r,n,s,l),[c,a,m,d]=o;return{allowed:c===1,remaining:a,limit:n,resetAt:m,retryAfter:d}}catch(o){return console.error("Rate limiter Redis error:",o),{allowed:!0,remaining:n,limit:n,resetAt:l+s,retryAfter:0}}}async increment(e){const i=`${this.prefix}${e}`;try{await this.client.eval(this.incrementScript,1,i)}catch(n){console.error("Rate limiter Redis increment error:",n)}}async reset(e){const i=`${this.prefix}${e}`;await this.client.del(i)}}exports.MemoryStore=p;exports.RedisStore=v;exports.ipAddress=b;exports.rateLimit=M;exports.validate=R;exports.validateQuery=A;