Skip to content

Commit

Permalink
Merge pull request #2 from harlan-zw/feat/review-feedback
Browse files Browse the repository at this point in the history
  • Loading branch information
timb-103 authored Aug 10, 2023
2 parents 7bfd965 + 7b87704 commit 42ad539
Show file tree
Hide file tree
Showing 7 changed files with 110 additions and 74 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ By default, this module will add a rate limit to any requests to a `/api` endpoi
- 🛑 Set rate limits per API route
- 🕒 Returns seconds until reset
- ⚡ Takes seconds to setup
- 🧾 Response x-ratelimit headers

## Quick Setup

Expand All @@ -37,6 +38,7 @@ That's it! You can now use Nuxt Rate Limit in your Nuxt app ✨
| name | type | default | description |
| --- | --- | --- | --- |
| `enabled` | `boolean` | `true` | Enabled/disable the rate limit module |
| `enabled` | `boolean` | `true` | Add x-ratelimit headers to response |
| `routes` | `object` | [`{}`](https://github.com/timb-103/nuxt-rate-limit/edit/master/README.md#default-rate-limit) | [Add rate limits per route](https://github.com/timb-103/nuxt-rate-limit/edit/master/README.md#different-limits-per-route) |

## Default Rate Limit
Expand Down
15 changes: 12 additions & 3 deletions playground/app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@
<p>intervalSeconds: 10</p>
<p>response: {{ helloResponse }}</p>
<button @click="hello()">Send request to /hello</button>
<div class="group" v-if="helloPayload">
<pre v-html="JSON.stringify(helloPayload)"></pre>
</div>
</div>

<div class="group">
<p>url: /api/hello</p>
<p>url: /api/goodbye</p>
<p>maxRequests: 10</p>
<p>intervalSeconds: 60</p>
<p>response: {{ goodbyeResponse }}</p>
Expand All @@ -27,13 +30,19 @@ import { ref } from '#imports'
const helloResponse = ref('')
const goodbyeResponse = ref('')
const helloPayload = ref({})
async function hello() {
helloResponse.value = ''
try {
const response = await $fetch('/api/hello')
helloResponse.value = response
const response = await $fetch.raw('/api/hello')
helloPayload.value = {
current: response.headers.get('x-ratelimit-current'),
limit: response.headers.get('x-ratelimit-limit'),
reset: response.headers.get('x-ratelimit-reset'),
}
helloResponse.value = response._data || ''
} catch (error: any) {
helloResponse.value = error.statusMessage
}
Expand Down
19 changes: 16 additions & 3 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type { RateLimitRoutes } from './types'
export interface ModuleOptions {
enabled: boolean
routes: RateLimitRoutes
headers: boolean
}

export interface RateLimitOptions {
Expand All @@ -20,6 +21,7 @@ export default defineNuxtModule<ModuleOptions>({
// Default configuration options of the Nuxt module
defaults: {
enabled: true,
headers: true,
routes: {
'/api/*': {
intervalSeconds: 60,
Expand All @@ -29,13 +31,24 @@ export default defineNuxtModule<ModuleOptions>({
},
async setup(options, nuxt) {
const resolver = createResolver(import.meta.url)
// opt-out early
if (!options.enabled) {
return
}

// add options to runtime config
nuxt.options.runtimeConfig.public.nuxtRateLimit = defu(
nuxt.options.runtimeConfig.public.nuxtRateLimit,
options
nuxt.options.runtimeConfig.nuxtRateLimit = defu(
nuxt.options.runtimeConfig.nuxtRateLimit,
{ headers: options.headers }
)

// merge with route rules, so we get free route matching with baseURL support
for (const [route, rules] of Object.entries(options.routes)) {
nuxt.options.nitro.routeRules = defu(nuxt.options.nitro.routeRules, {
[route]: { ['nuxt-rate-limit']: { ...rules, route } },
})
}

// add the rate-limit middleware
addServerHandler({
handler: resolver.resolve('./runtime/server/middleware/rate-limit'),
Expand Down
32 changes: 20 additions & 12 deletions src/runtime/server/middleware/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { defineEventHandler, createError } from 'h3'
import { isRateLimited } from '../utils/rate-limit'
import { defineEventHandler, createError, setHeader } from 'h3'
import { getRateLimitPayload} from '../utils/rate-limit'
import { useRuntimeConfig } from '#imports'

/**
Expand All @@ -8,17 +8,25 @@ import { useRuntimeConfig } from '#imports'
* Only works on API routes, eg: /api/hello
*/
export default defineEventHandler(async (event) => {
const isAPI = event.node.req.url?.includes('/api/')
const isEnabled = useRuntimeConfig().public.nuxtRateLimit.enabled
const { headers } = useRuntimeConfig().nuxtRateLimit

if (isAPI && isEnabled) {
const limited = isRateLimited(event)
const payload = getRateLimitPayload(event)
// route does not have rate limiting configured
if (!payload) {
return
}
const { limited, current, limit, secondsUntilReset } = payload

if (headers) {
setHeader(event, 'x-ratelimit-current', current)
setHeader(event, 'x-ratelimit-limit', limit)
setHeader(event, 'x-ratelimit-reset', secondsUntilReset)
}

if (limited) {
throw createError({
statusCode: 429,
statusMessage: `Too many requests. Please try again in ${limited} seconds.`,
})
}
if (limited) {
throw createError({
statusCode: 429,
statusMessage: `Too many requests. Please try again in ${secondsUntilReset} seconds.`,
})
}
})
97 changes: 47 additions & 50 deletions src/runtime/server/utils/rate-limit.ts
Original file line number Diff line number Diff line change
@@ -1,76 +1,73 @@
import { H3Event, getRequestHeader } from 'h3'
import { useRuntimeConfig } from '#imports'
import type { RateLimit, RateLimitRoutes } from '../../../types'
import { getRouteRules } from '#imports'
import type { RateLimit } from '../../../types'
import { RouteRateLimit } from '../../../types'

interface RateLimitResponse {
limited: boolean
limit: number
current: number
secondsUntilReset: number
}

// store rate limits for each IP address and URL
const rateLimit: RateLimit = {}

// the routes we will rate limit
const routes: RateLimitRoutes = useRuntimeConfig().public.nuxtRateLimit.routes

/**
* This function checks whether a request from a given IP address and URL should be rate limited
*
* If rate limited it will return the seconds until they can try again.
*
* @param event
*/
export function isRateLimited(event: H3Event) {
try {
const urlWithParams = event.node.req?.url as string
const ip = getIP(event)

// Strip any query parameters from the URL
const splitURL = urlWithParams.split('?')[0]
// const url = urlWithParams.split('?')[0]

// configure the route settings
const url = routes[splitURL] ? splitURL : '/api/*'
const maxRequests = routes[url].maxRequests
const interval = routes[url].intervalSeconds * 1000
export function getRateLimitPayload(event: H3Event): false | RateLimitResponse {
const routeRules = getRouteRules(event)
if (!routeRules['nuxt-rate-limit']) {
return false
}

// remove any IPs & URLs that haven't been used since interval to keep object small
const currentTime = Date.now()
Object.keys(rateLimit).forEach((key: keyof RateLimit) => {
Object.keys(rateLimit[key]).forEach((urlKey: string) => {
const item = rateLimit[key][urlKey]
const timeSinceLastRequest = currentTime - item.firstRequestTime
const routeInterval = routes[urlKey].intervalSeconds * 1000
const { maxRequests, intervalSeconds, route }: RouteRateLimit = routeRules['nuxt-rate-limit']
const intervalMs = intervalSeconds * 1000
const ip = getIP(event)

// remove the url
if (timeSinceLastRequest >= routeInterval) {
delete rateLimit[key][urlKey]
}
})
// remove any IPs & URLs that haven't been used since interval to keep object small
const currentTime = Date.now()
Object.keys(rateLimit).forEach((key: keyof RateLimit) => {
Object.keys(rateLimit[key]).forEach((urlKey: string) => {
const item = rateLimit[key][urlKey]
const timeSinceLastRequest = currentTime - item.firstRequestTime
const routeInterval = intervalSeconds * 1000

// remove the ip
if (!Object.keys(rateLimit[key]).length) {
delete rateLimit[key]
// remove the url
if (timeSinceLastRequest >= routeInterval) {
delete rateLimit[key][urlKey]
}
})

// add a rate limit object, or set to existing
rateLimit[ip] = rateLimit[ip] || {}
rateLimit[ip][url] = rateLimit[ip][url] || {
firstRequestTime: Number(new Date()),
requests: 0,
// remove the ip
if (!Object.keys(rateLimit[key]).length) {
delete rateLimit[key]
}
})

// check if the IP & URL is rate limited, return seconds until reset if it is
const requests = rateLimit[ip][url].requests
if (requests >= maxRequests) {
const timeSinceFirstRequest = currentTime - rateLimit[ip][url].firstRequestTime
const secondsUntilReset = Math.ceil((interval - timeSinceFirstRequest) / 1000)
return secondsUntilReset
}
// add a rate limit object, or set to existing
rateLimit[ip] = rateLimit[ip] || {}
// we index the rate limiting based on the route rule route, this allows us to rate limit a wild card entry using the same
// rate limit object, eg: /api/* will use the same rate limit object for /api/foo and /api/bar
rateLimit[ip][route] = rateLimit[ip][route] || {
firstRequestTime: Number(new Date()),
requests: 0,
}

// increment the requests counter
rateLimit[ip][url].requests++
} catch (error) {
console.log('Error checking rate limits:', error)
const timeSinceFirstRequest = currentTime - rateLimit[ip][route].firstRequestTime
const secondsUntilReset = Math.ceil((intervalMs - timeSinceFirstRequest) / 1000)
const limited = rateLimit[ip][route].requests >= maxRequests
// increment the requests counter
if (!limited) {
rateLimit[ip][route].requests++
}

return 0
return { limited, limit: maxRequests, current: rateLimit[ip][route].requests, secondsUntilReset }
}

/**
Expand Down
16 changes: 12 additions & 4 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
export interface RouteRateLimit {
maxRequests: number
intervalSeconds: number
route: string
}

export interface RouteRateLimitOptions {
maxRequests: number
intervalSeconds: number
}

export interface RateLimitRoutes {
[route: string]: {
maxRequests: number
intervalSeconds: number
}
[route: string]: RouteRateLimitOptions
}

export interface RateLimit {
Expand Down
3 changes: 1 addition & 2 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
{
"extends": "./playground/.nuxt/tsconfig.json",
"include": ["src", "playground", "./playground/.nuxt/nuxt.d.ts", "./dist/types.d.ts"]
"extends": "./playground/.nuxt/tsconfig.json"
}

0 comments on commit 42ad539

Please sign in to comment.