Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add `rateLimit()` fixed-window middleware with explicit client keys, named composable policies, atomic store integration, current IETF RateLimit draft response fields, and an opt-in single-process memory store (see #11576).
5 changes: 5 additions & 0 deletions packages/rate-limit-middleware/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# `rate-limit-middleware` CHANGELOG

This is the changelog for [`rate-limit-middleware`](https://github.com/remix-run/remix/tree/main/packages/rate-limit-middleware). It follows [semantic versioning](https://semver.org/).

## Unreleased
21 changes: 21 additions & 0 deletions packages/rate-limit-middleware/LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2025 Shopify Inc.

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
154 changes: 154 additions & 0 deletions packages/rate-limit-middleware/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
# rate-limit-middleware

Fixed-window rate limiting middleware for Remix Fetch API servers. It uses explicit client keys and named policies, supports atomic shared stores, and emits current `RateLimit` response fields.

## Features

- **Explicit Identity** - Applications choose a stable client key from authenticated or trusted server data
- **Named Policies** - Compose global, route, and user limits without store-key or response-field collisions
- **Atomic Stores** - Use the included single-process memory store or provide a shared store
- **Standard Response Fields** - Emit named `RateLimit`, `RateLimit-Policy`, and `Retry-After` values
- **Custom Rejections** - Return application-specific bodies while preserving status and rate limit fields

## Installation

```sh
npm i remix
```

## Usage

Install the middleware after the middleware that establishes the identity used by `key`. This example assumes `ClientId` was populated by authentication middleware earlier in the stack.

```ts
import { createContextKey, createRouter } from 'remix/router'
import { memoryStore, rateLimit } from 'remix/middleware/rate-limit'

let ClientId = createContextKey<string>()

let router = createRouter({
middleware: [
authenticateClient({ contextKey: ClientId }),
rateLimit({
name: 'api',
limit: 100,
window: 60_000,
key(context) {
let clientId = context.get(ClientId)
if (clientId == null) throw new Error('Expected an authenticated client')
return clientId
},
store: memoryStore(),
}),
],
})

router.get('/api/projects', () => Response.json([{ id: 'p1', name: 'Remix' }]))
```

The first request includes the named policy and its current state:

```http
RateLimit-Policy: "api";q=100;w=60
RateLimit: "api";r=99;t=60
```

Request 101 is rejected before route handling and includes `Retry-After`:

```http
HTTP/1.1 429 Too Many Requests
RateLimit-Policy: "api";q=100;w=60
RateLimit: "api";r=0;t=42
Retry-After: 42
```

## Client Keys

`key` is required because a Fetch `Request` does not expose a trusted client address. Return a stable, non-secret identifier derived from authenticated identity or trusted server data. Keys must contain between 1 and 1,024 characters.

Do not use a raw authorization header, cookie, user agent, or untrusted forwarding header. Attackers can rotate those values to bypass limits, while shared values can cause unrelated clients to consume the same quota.

## Named Policies

Policy names namespace store buckets and allow multiple limiters to compose. Names start with a letter and contain at most 64 letters, numbers, dots, underscores, or dashes.

```ts
let store = memoryStore()

let router = createRouter({
middleware: [
rateLimit({
name: 'global',
limit: 1_000,
window: 60_000,
key: getClientId,
store,
}),
rateLimit({
name: 'expensive-route',
limit: 10,
window: 60_000,
key: getClientId,
store,
}),
],
})
```

Each policy is counted independently and appended to the response fields.

## Custom Limit Responses

Use `onLimitExceeded` for JSON or another application-specific body. The middleware normalizes the status to `429` and adds the policy fields and `Retry-After`.

```ts
rateLimit({
name: 'api',
limit: 100,
window: 60_000,
key: getClientId,
store,
onLimitExceeded(_context, state) {
return Response.json({
error: 'rate_limit_exceeded',
policy: state.name,
retryAfter: state.retryAfter,
})
},
})
```

## Stores

`memoryStore()` uses generational maps so increments do not scan all client buckets. It is suitable for tests, local development, and deliberate single-process deployments. It does not coordinate limits across processes or hosts and should not be mistaken for denial-of-service protection.

Production deployments with multiple processes or hosts should provide a shared store whose `increment()` operation creates or increments the window atomically:

```ts
import type { RateLimitStore } from 'remix/middleware/rate-limit'

let store: RateLimitStore = {
async increment({ name, key, window }) {
return redisIncrementFixedWindow({
key: `rate-limit:${name}:${key}`,
window,
})
},
}
```

The store returns a positive safe-integer `count` and a positive safe-integer `resetAt` Unix timestamp in milliseconds. Store failures propagate so applications do not silently bypass limits.

## Related Packages

- [`fetch-router`](https://github.com/remix-run/remix/tree/main/packages/fetch-router) - Router and typed middleware context
- [`auth-middleware`](https://github.com/remix-run/remix/tree/main/packages/auth-middleware) - Authenticated request identity
- [`node-fetch-server`](https://github.com/remix-run/remix/tree/main/packages/node-fetch-server) - Node.js server adapter with trusted client address information

## Related Work

- [IETF RateLimit header fields](https://datatracker.ietf.org/doc/html/draft-ietf-httpapi-ratelimit-headers)

## License

See [LICENSE](https://github.com/remix-run/remix/blob/main/LICENSE)
59 changes: 59 additions & 0 deletions packages/rate-limit-middleware/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
{
"name": "@remix-run/rate-limit-middleware",
"version": "0.0.0",
"description": "Middleware for rate limiting requests in Fetch API servers",
"author": "Michael Jackson <mjijackson@gmail.com>",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/remix-run/remix.git",
"directory": "packages/rate-limit-middleware"
},
"homepage": "https://github.com/remix-run/remix/tree/main/packages/rate-limit-middleware#readme",
"files": [
"LICENSE",
"README.md",
"dist",
"src",
"!src/**/*.test.ts"
],
"type": "module",
"exports": {
".": "./src/index.ts",
"./package.json": "./package.json"
},
"publishConfig": {
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./package.json": "./package.json"
}
},
"devDependencies": {
"@remix-run/assert": "workspace:^",
"@remix-run/test": "workspace:^",
"@types/node": "catalog:",
"@typescript/native-preview": "catalog:"
},
"dependencies": {
"@remix-run/fetch-router": "workspace:^"
},
"scripts": {
"build": "tsgo -p tsconfig.build.json",
"clean": "git clean -fdX",
"prepublishOnly": "pnpm run build",
"test": "remix-test",
"test:bun": "bun x --bun remix-test",
"typecheck": "tsgo --noEmit"
},
"keywords": [
"fetch",
"router",
"middleware",
"rate-limit",
"ratelimit",
"throttle"
]
}
7 changes: 7 additions & 0 deletions packages/rate-limit-middleware/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export { rateLimit, type RateLimitOptions, type RateLimitState } from './lib/rate-limit.ts'
export {
memoryStore,
type RateLimitStore,
type RateLimitStoreEntry,
type RateLimitStoreIncrement,
} from './lib/store.ts'
41 changes: 41 additions & 0 deletions packages/rate-limit-middleware/src/lib/headers.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import * as assert from '@remix-run/assert'
import { describe, it } from '@remix-run/test'

import { createRateLimitHeaderValues } from './headers.ts'

describe('RateLimit response fields', () => {
it('serializes a named policy using the current structured field syntax', () => {
let values = createRateLimitHeaderValues(
{
count: 1,
limit: 100,
name: 'api',
remaining: 99,
resetAt: 61_000,
retryAfter: 60,
},
60_000,
)

assert.deepEqual(values, {
rateLimit: '"api";r=99;t=60',
rateLimitPolicy: '"api";q=100;w=60',
})
})

it('rounds sub-second windows up to one second', () => {
let values = createRateLimitHeaderValues(
{
count: 1,
limit: 1,
name: 'burst',
remaining: 0,
resetAt: 1_001,
retryAfter: 1,
},
1,
)

assert.equal(values.rateLimitPolicy, '"burst";q=1;w=1')
})
})
58 changes: 58 additions & 0 deletions packages/rate-limit-middleware/src/lib/headers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import type { RateLimitState } from './rate-limit.ts'

interface RateLimitHeaderValues {
rateLimit: string
rateLimitPolicy: string
}

/**
* Adds current RateLimit draft fields to a response without replacing other named policies.
*
* @param response Response to decorate.
* @param state Current policy state.
* @param window Window size in milliseconds.
* @param retryAfter Whether to add `Retry-After` for a rejected request.
* @returns A response with rate limit fields.
*/
export function withRateLimitHeaders(
response: Response,
state: RateLimitState,
window: number,
retryAfter: boolean,
): Response {
let headers = new Headers(response.headers)
let values = createRateLimitHeaderValues(state, window)

headers.append('Ratelimit', values.rateLimit)
headers.append('Ratelimit-Policy', values.rateLimitPolicy)

if (retryAfter) {
headers.set('Retry-After', String(state.retryAfter))
}

return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
})
}

/**
* Serializes one named policy using the current IETF RateLimit field syntax.
*
* @param state Current policy state.
* @param window Window size in milliseconds.
* @returns Serialized `RateLimit` and `RateLimit-Policy` values.
*/
export function createRateLimitHeaderValues(
state: RateLimitState,
window: number,
): RateLimitHeaderValues {
let name = JSON.stringify(state.name)
let windowSeconds = Math.max(1, Math.ceil(window / 1000))

return {
rateLimit: `${name};r=${state.remaining};t=${state.retryAfter}`,
rateLimitPolicy: `${name};q=${state.limit};w=${windowSeconds}`,
}
}
Loading