├── .github
├── img
│ └── dashboard.png
└── workflows
│ ├── release.yml
│ └── tests.yaml
├── .gitignore
├── .prettierignore
├── LICENSE
├── README.md
├── bun.lockb
├── cmd
└── set-version.js
├── context7.json
├── eslint.config.mjs
├── examples
├── cloudflare-pages
│ ├── .eslintrc.json
│ ├── .gitignore
│ ├── README.md
│ ├── app
│ │ ├── api
│ │ │ └── route.ts
│ │ ├── favicon.ico
│ │ ├── globals.css
│ │ ├── layout.tsx
│ │ ├── not-found.tsx
│ │ └── page.tsx
│ ├── env.d.ts
│ ├── next.config.mjs
│ ├── package.json
│ ├── postcss.config.js
│ ├── public
│ │ ├── next.svg
│ │ └── vercel.svg
│ ├── tailwind.config.ts
│ ├── tsconfig.json
│ └── wrangler.toml
├── cloudflare-workers
│ ├── .gitignore
│ ├── README.md
│ ├── bun.lockb
│ ├── ci.test.ts
│ ├── package.json
│ ├── src
│ │ └── index.ts
│ ├── tsconfig.json
│ └── wrangler.toml
├── deno
│ ├── README.md
│ ├── deprecated.ts
│ └── main.ts
├── enable-protection
│ ├── .eslintrc.json
│ ├── .gitignore
│ ├── README.md
│ ├── app
│ │ ├── api
│ │ │ └── route.ts
│ │ ├── favicon.ico
│ │ ├── globals.css
│ │ ├── layout.tsx
│ │ └── page.tsx
│ ├── next.config.mjs
│ ├── package.json
│ ├── postcss.config.mjs
│ ├── public
│ │ ├── next.svg
│ │ └── vercel.svg
│ ├── tailwind.config.ts
│ └── tsconfig.json
├── nextjs-middleware
│ ├── .eslintrc.json
│ ├── .gitignore
│ ├── README.md
│ ├── app
│ │ ├── api
│ │ │ ├── blocked
│ │ │ │ └── route.tsx
│ │ │ └── route.ts
│ │ ├── favicon.ico
│ │ ├── globals.css
│ │ ├── layout.tsx
│ │ └── page.tsx
│ ├── middleware.ts
│ ├── next.config.mjs
│ ├── package.json
│ ├── postcss.config.mjs
│ ├── public
│ │ ├── next.svg
│ │ └── vercel.svg
│ ├── tailwind.config.ts
│ └── tsconfig.json
├── nextjs
│ ├── .eslintrc.json
│ ├── .gitignore
│ ├── README.md
│ ├── app
│ │ ├── api
│ │ │ └── route.ts
│ │ ├── favicon.ico
│ │ ├── globals.css
│ │ ├── layout.tsx
│ │ └── page.tsx
│ ├── bun.lockb
│ ├── ci.test.ts
│ ├── next-env.d.ts
│ ├── next.config.mjs
│ ├── package.json
│ ├── pages
│ │ └── api
│ │ │ └── pages-test.ts
│ ├── postcss.config.mjs
│ ├── public
│ │ ├── next.svg
│ │ └── vercel.svg
│ ├── tailwind.config.ts
│ └── tsconfig.json
├── remix
│ ├── .env.example
│ ├── .eslintrc.js
│ ├── .gitignore
│ ├── README.md
│ ├── app
│ │ ├── root.tsx
│ │ └── routes
│ │ │ └── index.tsx
│ ├── package.json
│ ├── public
│ │ └── favicon.ico
│ ├── remix.config.js
│ ├── remix.env.d.ts
│ ├── server.js
│ └── tsconfig.json
├── vercel-edge
│ ├── .eslintrc.json
│ ├── .gitignore
│ ├── README.md
│ ├── app
│ │ ├── api
│ │ │ └── route.ts
│ │ ├── favicon.ico
│ │ ├── globals.css
│ │ ├── layout.tsx
│ │ └── page.tsx
│ ├── next.config.mjs
│ ├── package.json
│ ├── postcss.config.mjs
│ ├── public
│ │ ├── next.svg
│ │ └── vercel.svg
│ ├── tailwind.config.ts
│ └── tsconfig.json
└── with-vercel-kv
│ ├── .gitignore
│ ├── README.md
│ ├── app
│ ├── favicon.ico
│ ├── globals.css
│ ├── layout.tsx
│ └── page.tsx
│ ├── next.config.js
│ ├── package.json
│ ├── postcss.config.js
│ ├── public
│ ├── next.svg
│ └── vercel.svg
│ ├── tailwind.config.js
│ └── tsconfig.json
├── package.json
├── prettier.config.js
├── src
├── analytics.test.ts
├── analytics.ts
├── blockUntilReady.test.ts
├── cache.test.ts
├── cache.ts
├── deny-list
│ ├── deny-list.test.ts
│ ├── deny-list.ts
│ ├── index.ts
│ ├── integration.test.ts
│ ├── ip-deny-list.test.ts
│ ├── ip-deny-list.ts
│ ├── scripts.test.ts
│ ├── scripts.ts
│ ├── time.test.ts
│ └── time.ts
├── duration.test.ts
├── duration.ts
├── getRemainingTokens.test.ts
├── hash.test.ts
├── hash.ts
├── index.ts
├── lua-scripts
│ ├── hash.test.ts
│ ├── hash.ts
│ ├── multi.ts
│ ├── reset.ts
│ └── single.ts
├── multi.ts
├── ratelimit.test.ts
├── ratelimit.ts
├── resetUsedTokens.test.ts
├── single.ts
├── test_utils.ts
├── tools
│ └── seed.ts
└── types.ts
├── tsconfig.json
├── tsup.config.js
└── turbo.json
/.github/img/dashboard.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/upstash/ratelimit-js/a879eff5a9340ea3a523aa624ba318d40c0d3615/.github/img/dashboard.png
--------------------------------------------------------------------------------
/.github/workflows/release.yml:
--------------------------------------------------------------------------------
1 | name: Release
2 |
3 | on:
4 | release:
5 | types:
6 | - published
7 |
8 | jobs:
9 | release:
10 | name: Release
11 | runs-on:
12 | group: large-runners
13 | labels: ubuntu-4x
14 | steps:
15 | - name: Checkout Repo
16 | uses: actions/checkout@v3
17 |
18 | - name: Set env
19 | run: echo "VERSION=${GITHUB_REF#refs/*/}" >> $GITHUB_ENV
20 |
21 | - name: Setup Node
22 | uses: actions/setup-node@v2
23 | with:
24 | node-version: 18
25 |
26 | - name: Set package version
27 | run: echo $(jq --arg v "${{ env.VERSION }}" '(.version) = $v' package.json) > package.json
28 |
29 | - name: Setup Bun
30 | uses: oven-sh/setup-bun@v1
31 | with:
32 | bun-version: latest
33 |
34 | - name: Install dependencies
35 | run: bun install
36 |
37 | - name: Build
38 | run: bun run build
39 |
40 | - name: Add npm token
41 | run: echo "//registry.npmjs.org/:_authToken=${{secrets.NPM_TOKEN}}" > .npmrc
42 |
43 | - name: Publish release candidate
44 | if: "github.event.release.prerelease"
45 | run: npm publish --access public --tag=canary --no-git-checks
46 |
47 | - name: Publish
48 | if: "!github.event.release.prerelease"
49 | run: npm publish --access public --no-git-checks
50 |
--------------------------------------------------------------------------------
/.github/workflows/tests.yaml:
--------------------------------------------------------------------------------
1 | name: Tests
2 | on:
3 | pull_request:
4 | schedule:
5 | - cron: "0 0 * * *" # daily
6 |
7 | env:
8 | UPSTASH_REDIS_REST_URL: ${{ secrets.UPSTASH_REDIS_REST_URL }}
9 | UPSTASH_REDIS_REST_TOKEN: ${{ secrets.UPSTASH_REDIS_REST_TOKEN }}
10 |
11 | US1_UPSTASH_REDIS_REST_URL: ${{ secrets.US1_UPSTASH_REDIS_REST_URL }}
12 | US1_UPSTASH_REDIS_REST_TOKEN: ${{ secrets.US1_UPSTASH_REDIS_REST_TOKEN }}
13 |
14 | APN_UPSTASH_REDIS_REST_URL: ${{ secrets.APN_UPSTASH_REDIS_REST_URL }}
15 | APN_UPSTASH_REDIS_REST_TOKEN: ${{ secrets.APN_UPSTASH_REDIS_REST_TOKEN }}
16 |
17 | EU2_UPSTASH_REDIS_REST_URL: ${{ secrets.EU2_UPSTASH_REDIS_REST_URL }}
18 | EU2_UPSTASH_REDIS_REST_TOKEN: ${{ secrets.EU2_UPSTASH_REDIS_REST_TOKEN }}
19 |
20 | jobs:
21 | test:
22 | runs-on:
23 | group: large-runners
24 | labels: ubuntu-4x
25 | name: Tests
26 | steps:
27 | - name: Setup repo
28 | uses: actions/checkout@v2
29 |
30 | - name: Setup Bun
31 | uses: oven-sh/setup-bun@v1
32 | with:
33 | bun-version: latest
34 |
35 | - name: Install dependencies
36 | run: bun install
37 |
38 | - name: Run tests
39 | run: bun run test
40 |
41 | cloudflare-workers-local:
42 | runs-on: ubuntu-latest
43 | steps:
44 | - name: Setup repo
45 | uses: actions/checkout@v3
46 | - name: Setup nodejs
47 | uses: actions/setup-node@v3
48 |
49 | - name: Setup Bun
50 | uses: oven-sh/setup-bun@v1
51 | with:
52 | bun-version: latest
53 |
54 | - name: Install dependencies
55 | run: bun install
56 |
57 | - name: Build
58 | run: bun run build
59 |
60 | - name: Install example
61 | run: bun add @upstash/ratelimit@../..
62 | working-directory: examples/cloudflare-workers
63 |
64 | - name: Add environment
65 | run: |
66 | echo '[vars]' >> wrangler.toml
67 | echo "UPSTASH_REDIS_REST_URL = \"$UPSTASH_REDIS_REST_URL\"" >> ./wrangler.toml
68 | echo "UPSTASH_REDIS_REST_TOKEN = \"$UPSTASH_REDIS_REST_TOKEN\"" >> ./wrangler.toml
69 | working-directory: examples/cloudflare-workers
70 |
71 | - name: Start example
72 | run: bun dev &
73 | working-directory: examples/cloudflare-workers
74 |
75 | - name: Run tests
76 | run: bun test ci.test.ts
77 | working-directory: examples/cloudflare-workers
78 | env:
79 | DEPLOYMENT_URL: http://127.0.0.1:8787
80 |
81 | cloudflare-workers-deployed:
82 | concurrency: cloudflare-workers-deployed
83 | needs:
84 | - release
85 | runs-on: ubuntu-latest
86 | steps:
87 | - name: Setup repo
88 | uses: actions/checkout@v3
89 | - name: Setup nodejs
90 | uses: actions/setup-node@v3
91 | with:
92 | node-version: 18
93 |
94 | - name: Setup Bun
95 | uses: oven-sh/setup-bun@v1
96 | with:
97 | bun-version: latest
98 |
99 | - name: Install example
100 | run: |
101 | bun add @upstash/ratelimit@${{needs.release.outputs.version}}
102 | npm i -g wrangler
103 | working-directory: examples/cloudflare-workers
104 |
105 | - name: Add account ID
106 | run: echo 'account_id = "${{ secrets.CLOUDFLARE_ACCOUNT_ID }}"' >> wrangler.toml
107 | working-directory: examples/cloudflare-workers
108 |
109 | - name: Add environment
110 | run: |
111 | echo '[vars]' >> wrangler.toml
112 | echo "UPSTASH_REDIS_REST_URL = \"$UPSTASH_REDIS_REST_URL\"" >> ./wrangler.toml
113 | echo "UPSTASH_REDIS_REST_TOKEN = \"$UPSTASH_REDIS_REST_TOKEN\"" >> ./wrangler.toml
114 | working-directory: examples/cloudflare-workers
115 |
116 | - name: Deploy
117 | run: wrangler publish
118 | working-directory: examples/cloudflare-workers
119 | env:
120 | CLOUDFLARE_API_TOKEN: ${{secrets.CLOUDFLARE_API_TOKEN}}
121 |
122 | - name: Test
123 | run: bun test examples/cloudflare-workers/ci.test.ts
124 | env:
125 | DEPLOYMENT_URL: https://upstash-ratelimit.upsdev.workers.dev
126 |
127 | nextjs-local:
128 | runs-on: ubuntu-latest
129 | steps:
130 | - name: Setup repo
131 | uses: actions/checkout@v3
132 | - name: Setup nodejs
133 | uses: actions/setup-node@v3
134 |
135 | - name: Setup Bun
136 | uses: oven-sh/setup-bun@v1
137 | with:
138 | bun-version: latest
139 |
140 | - name: Install Dependencies
141 | run: bun install
142 |
143 | - name: Build
144 | run: bun run build
145 |
146 | - name: Install example
147 | run: bun add @upstash/ratelimit@../..
148 | working-directory: examples/nextjs
149 |
150 | - name: Build example
151 | run: bun run build
152 | working-directory: examples/nextjs
153 |
154 | - name: Run example
155 | run: npm run start &
156 | working-directory: examples/nextjs
157 |
158 | - name: Test
159 | run: bun test ci.test.ts
160 | working-directory: examples/nextjs
161 | env:
162 | DEPLOYMENT_URL: http://localhost:3000
163 |
164 |
165 | nextjs-deployed:
166 | concurrency: nextjs-deployed
167 | runs-on: ubuntu-latest
168 | needs:
169 | - release
170 | steps:
171 | - name: Setup repo
172 | uses: actions/checkout@v3
173 |
174 | - name: Setup node
175 | uses: actions/setup-node@v3
176 | with:
177 | node-version: 20
178 |
179 | - name: Setup Bun
180 | uses: oven-sh/setup-bun@v1
181 | with:
182 | bun-version: latest
183 |
184 | - name: Install Vercel CLI
185 | run: npm install --global vercel@latest
186 |
187 | - name: Pull Vercel Environment Information
188 | run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
189 | env:
190 | VERCEL_ORG_ID: ${{ secrets.VERCEL_TEAM_ID }}
191 | VERCEL_PROJECT_ID: "prj_NSOSq2ZawugKhtZGb3ViHX4b56hx"
192 | working-directory: examples/nextjs
193 |
194 | - name: Install @upstash/ratelimit canary version
195 | run: npm install @upstash/ratelimit@${{needs.release.outputs.version}}
196 | working-directory: examples/nextjs
197 |
198 | - name: Build Project
199 | run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
200 | env:
201 | VERCEL_ORG_ID: ${{ secrets.VERCEL_TEAM_ID }}
202 | VERCEL_PROJECT_ID: "prj_NSOSq2ZawugKhtZGb3ViHX4b56hx"
203 | working-directory: examples/nextjs
204 |
205 | - name: Deploy to Vercel
206 | run: |
207 | DEPLOYMENT_URL=$(vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }} \
208 | --env UPSTASH_REDIS_REST_URL=${{ secrets.UPSTASH_REDIS_REST_URL }} \
209 | --env UPSTASH_REDIS_REST_TOKEN=${{ secrets.UPSTASH_REDIS_REST_TOKEN }})
210 | echo "DEPLOYMENT_URL=${DEPLOYMENT_URL}" >> $GITHUB_ENV
211 | env:
212 | VERCEL_ORG_ID: ${{ secrets.VERCEL_TEAM_ID }}
213 | VERCEL_PROJECT_ID: "prj_NSOSq2ZawugKhtZGb3ViHX4b56hx"
214 | working-directory: examples/nextjs
215 |
216 | - name: Test
217 | run: bun test ci.test.ts
218 | working-directory: examples/nextjs
219 |
220 | release:
221 | name: Release
222 | concurrency: release
223 | needs:
224 | - cloudflare-workers-local
225 | - nextjs-local
226 |
227 | runs-on: ubuntu-latest
228 | outputs:
229 | version: ${{ steps.version.outputs.version }}
230 | steps:
231 | - name: Checkout Repo
232 | uses: actions/checkout@v3
233 |
234 | - name: Get version
235 | id: version
236 | run: echo "::set-output name=version::v0.0.0-ci.${GITHUB_SHA}-$(date +%Y%m%d%H%M%S)"
237 |
238 | - name: Setup Node
239 | uses: actions/setup-node@v2
240 | with:
241 | node-version: 18
242 |
243 | - name: Set package version
244 | run: echo $(jq --arg v "${{ steps.version.outputs.version }}" '(.version) = $v' package.json) > package.json
245 |
246 | - name: Setup Bun
247 | uses: oven-sh/setup-bun@v1
248 | with:
249 | bun-version: latest
250 |
251 | - name: Install dependencies
252 | run: bun install
253 |
254 | - name: Build
255 | run: bun run build
256 |
257 | - name: Add npm token
258 | run: echo "//registry.npmjs.org/:_authToken=${{secrets.NPM_TOKEN}}" > .npmrc
259 |
260 | - name: Publish release candidate
261 | run: npm publish --access public --tag=ci
262 |
263 | - name: Sleep
264 | run: sleep 5
265 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | .env
4 | redis-server
5 |
6 | **/yarn.lock
7 | **/package-lock.json
8 | **/pnpm-lock.yaml
9 |
10 | *.log
11 | .vercel
12 | .next
13 |
14 | coverage
15 |
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | dist
2 | examples
3 | node_modules
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2021 Upstash, Inc.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Upstash Rate Limit
2 |
3 | [](https://www.npmjs.com/package/@upstash/ratelimit)
4 | [](https://github.com/upstash/ratelimit/actions/workflows/tests.yaml)
5 |
6 | > [!NOTE]
7 | > **This project is in GA Stage.**
8 | > The Upstash Professional Support fully covers this project. It receives regular updates, and bug fixes. The Upstash team is committed to maintaining and improving its functionality.
9 |
10 | It is the only connectionless (HTTP based) rate limiting library and designed
11 | for:
12 |
13 | - Serverless functions (AWS Lambda, Vercel ....)
14 | - Cloudflare Workers & Pages
15 | - Vercel Edge
16 | - Fastly Compute@Edge
17 | - Next.js, Jamstack ...
18 | - Client side web/mobile applications
19 | - WebAssembly
20 | - and other environments where HTTP is preferred over TCP.
21 |
22 | ## Quick Start
23 |
24 | ### Install
25 |
26 | #### npm
27 |
28 | ```bash
29 | npm install @upstash/ratelimit
30 | ```
31 |
32 | #### Deno
33 |
34 | ```ts
35 | import { Ratelimit } from "https://cdn.skypack.dev/@upstash/ratelimit@latest";
36 | ```
37 |
38 | ### Create database
39 |
40 | Create a new redis database on [upstash](https://console.upstash.com/). See [here](https://github.com/upstash/upstash-redis#quick-start) for documentation on how to create a redis instance.
41 |
42 | ### Basic Usage
43 |
44 | ```ts
45 | import { Ratelimit } from "@upstash/ratelimit"; // for deno: see above
46 | import { Redis } from "@upstash/redis"; // see below for cloudflare and fastly adapters
47 |
48 | // Create a new ratelimiter, that allows 10 requests per 10 seconds
49 | const ratelimit = new Ratelimit({
50 | redis: Redis.fromEnv(),
51 | limiter: Ratelimit.slidingWindow(10, "10 s"),
52 | analytics: true,
53 | /**
54 | * Optional prefix for the keys used in redis. This is useful if you want to share a redis
55 | * instance with other applications and want to avoid key collisions. The default prefix is
56 | * "@upstash/ratelimit"
57 | */
58 | prefix: "@upstash/ratelimit",
59 | });
60 |
61 | // Use a constant string to limit all requests with a single ratelimit
62 | // Or use a userID, apiKey or ip address for individual limits.
63 | const identifier = "api";
64 | const { success } = await ratelimit.limit(identifier);
65 |
66 | if (!success) {
67 | return "Unable to process at this time";
68 | }
69 | doExpensiveCalculation();
70 | return "Here you go!";
71 | ```
72 |
73 | For more information on getting started, you can refer to [our documentation](https://upstash.com/docs/oss/sdks/ts/ratelimit/gettingstarted).
74 |
75 | [Here's a complete nextjs example](https://github.com/upstash/ratelimit/tree/main/examples/nextjs)
76 |
77 | ## Documentation
78 |
79 | See [the documentation](https://upstash.com/docs/redis/sdks/ratelimit-ts/overview) for more information details about this package.
80 |
81 | ## Contributing
82 |
83 | ### Database
84 |
85 | Create a new redis database on [upstash](https://console.upstash.com/) and copy
86 | the url and token.
87 |
88 | ### Running tests
89 |
90 | To run the tests, you will need to set some environment variables. Here is a list of
91 | variables to set:
92 | - `UPSTASH_REDIS_REST_URL`
93 | - `UPSTASH_REDIS_REST_TOKEN`
94 | - `US1_UPSTASH_REDIS_REST_URL`
95 | - `US1_UPSTASH_REDIS_REST_TOKEN`
96 | - `APN_UPSTASH_REDIS_REST_URL`
97 | - `APN_UPSTASH_REDIS_REST_TOKEN`
98 | - `EU2_UPSTASH_REDIS_REST_URL`
99 | - `EU2_UPSTASH_REDIS_REST_TOKEN`
100 |
101 | You can create a single Upstash Redis and use its URL and token for all four above.
102 |
103 | Once you set the environment variables, simply run:
104 | ```sh
105 | pnpm test
106 | ```
107 |
--------------------------------------------------------------------------------
/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/upstash/ratelimit-js/a879eff5a9340ea3a523aa624ba318d40c0d3615/bun.lockb
--------------------------------------------------------------------------------
/cmd/set-version.js:
--------------------------------------------------------------------------------
1 | const fs = require("fs");
2 | const path = require("path");
3 |
4 | const root = process.argv[2]; // path to project root
5 | const version = process.argv[3].replace(/^v/, ""); // new version
6 |
7 | console.log(`Updating version=${version} in ${root}`);
8 |
9 | const content = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf-8"));
10 |
11 | content.version = version;
12 |
13 | fs.writeFileSync(path.join(root, "package.json"), JSON.stringify(content, null, 2));
14 | fs.writeFileSync(path.join(root, "src", "version.ts"), `export const VERSION = "${version}";`);
15 |
--------------------------------------------------------------------------------
/context7.json:
--------------------------------------------------------------------------------
1 | {
2 | "projectTitle": "Upstash Ratelimit JS",
3 | "description": "Rate limiting library based on Upstash Redis",
4 | "folders": [
5 | ],
6 | "excludeFolders": [
7 | "src",
8 | "cmd"
9 | ],
10 | "rules": [
11 | "Use Upstash Redis as the database.",
12 | "Use single region set up of Upstash."
13 | ],
14 | "previousVersions": [
15 | {
16 | "tag": "v2.0.0",
17 | "title": "version 2.0"
18 | },
19 | {
20 | "tag": "v1.0.3",
21 | "title": "version 1.0"
22 | }
23 | ]
24 | }
25 |
--------------------------------------------------------------------------------
/eslint.config.mjs:
--------------------------------------------------------------------------------
1 | import typescriptEslint from "@typescript-eslint/eslint-plugin";
2 | import unicorn from "eslint-plugin-unicorn";
3 | import path from "node:path";
4 | import { fileURLToPath } from "node:url";
5 | import js from "@eslint/js";
6 | import { FlatCompat } from "@eslint/eslintrc";
7 |
8 | const __filename = fileURLToPath(import.meta.url);
9 | const __dirname = path.dirname(__filename);
10 | const compat = new FlatCompat({
11 | baseDirectory: __dirname,
12 | recommendedConfig: js.configs.recommended,
13 | allConfig: js.configs.all,
14 | });
15 |
16 | export default [
17 | {
18 | ignores: ["**/*.config.*", "**/examples", "**/dist"],
19 | },
20 | ...compat.extends(
21 | "eslint:recommended",
22 | "plugin:unicorn/recommended",
23 | "plugin:@typescript-eslint/recommended"
24 | ),
25 | {
26 | plugins: {
27 | "@typescript-eslint": typescriptEslint,
28 | unicorn,
29 | },
30 |
31 | languageOptions: {
32 | globals: {},
33 | ecmaVersion: 5,
34 | sourceType: "script",
35 |
36 | parserOptions: {
37 | project: "./tsconfig.json",
38 | },
39 | },
40 |
41 | rules: {
42 | "no-console": [
43 | "error",
44 | {
45 | allow: ["warn", "error"],
46 | },
47 | ],
48 |
49 | "@typescript-eslint/no-magic-numbers": "off",
50 | "@typescript-eslint/unbound-method": "off",
51 | "@typescript-eslint/prefer-as-const": "error",
52 | "@typescript-eslint/consistent-type-imports": "error",
53 | "@typescript-eslint/no-explicit-any": "off",
54 | "@typescript-eslint/restrict-template-expressions": "off",
55 | "@typescript-eslint/consistent-type-definitions": ["error", "type"],
56 |
57 | "@typescript-eslint/no-unused-vars": [
58 | "error",
59 | {
60 | varsIgnorePattern: "^_",
61 | argsIgnorePattern: "^_",
62 | },
63 | ],
64 |
65 | "@typescript-eslint/prefer-ts-expect-error": "off",
66 |
67 | "@typescript-eslint/no-misused-promises": [
68 | "error",
69 | {
70 | checksVoidReturn: false,
71 | },
72 | ],
73 |
74 | "unicorn/prevent-abbreviations": "off",
75 |
76 | "no-implicit-coercion": [
77 | "error",
78 | {
79 | boolean: true,
80 | },
81 | ],
82 |
83 | "no-extra-boolean-cast": [
84 | "error",
85 | {
86 | enforceForLogicalOperands: true,
87 | },
88 | ],
89 |
90 | "no-unneeded-ternary": [
91 | "error",
92 | {
93 | defaultAssignment: true,
94 | },
95 | ],
96 |
97 | "unicorn/no-array-reduce": ["off"],
98 | "unicorn/no-nested-ternary": "off",
99 | "unicorn/no-null": "off",
100 | "unicorn/filename-case": "off",
101 | },
102 | },
103 | ];
104 |
--------------------------------------------------------------------------------
/examples/cloudflare-pages/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "next/core-web-vitals",
4 | "plugin:eslint-plugin-next-on-pages/recommended"
5 | ],
6 | "plugins": [
7 | "eslint-plugin-next-on-pages"
8 | ]
9 | }
--------------------------------------------------------------------------------
/examples/cloudflare-pages/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
38 | # wrangler files
39 | .wrangler
40 | .dev.vars
41 |
--------------------------------------------------------------------------------
/examples/cloudflare-pages/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`c3`](https://developers.cloudflare.com/pages/get-started/c3) which examplifies how one can use Upstash Ratelimit with Cloudflare Pages.
2 |
3 | The project was initialized with ([see CF guide](https://developers.cloudflare.com/pages/framework-guides/nextjs/deploy-a-nextjs-site/)):
4 |
5 | ```bash
6 | npm create cloudflare@latest cloudflare-pages -- --framework=next
7 | ```
8 |
9 | Then, the [page.tsx](https://github.com/upstash/ratelimit/blob/main/examples/cloudflare-pages/app/page.tsx) file was updated with a simple page to test ratelimiting. An api endpoint was added in [route.tsx](https://github.com/upstash/ratelimit/blob/main/examples/cloudflare-pages/app/api/route.tsx). This is where we define the rate limit like the following:
10 |
11 | ```tsx
12 | export const runtime = 'edge';
13 |
14 | export const dynamic = 'force-dynamic';
15 |
16 | import { Ratelimit } from "@upstash/ratelimit";
17 | import { Redis } from "@upstash/redis";
18 |
19 | // Create a new ratelimiter
20 | const ratelimit = new Ratelimit({
21 | redis: Redis.fromEnv(),
22 | limiter: Ratelimit.slidingWindow(10, "10 s"),
23 | prefix: "@upstash/ratelimit",
24 | });
25 |
26 | export async function GET(request: Request) {
27 |
28 | const identifier = "api";
29 | const { success, limit, remaining } = await ratelimit.limit(identifier);
30 | const response = {
31 | success: success,
32 | limit: limit,
33 | remaining: remaining
34 | }
35 |
36 | if (!success) {
37 | return new Response(JSON.stringify(response), { status: 429 });
38 | }
39 | return new Response(JSON.stringify(response));
40 | }
41 | ```
42 |
43 | ## Getting Started
44 |
45 | First, create an Upstash Redis through [the Upstash console](https://console.upstash.com/redis) and set the environment variables `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN`.
46 |
47 | Then, run the development server:
48 |
49 | ```bash
50 | npm run dev
51 | # or
52 | yarn dev
53 | # or
54 | pnpm dev
55 | # or
56 | bun dev
57 | ```
58 |
59 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
60 |
61 | ## Deployment
62 |
63 | To deploy the project, simply set the environment variables `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` through the Environment Variables section under the Settings tab on [the Cloudflare Dashboard](https://dash.cloudflare.com). Then, run:
64 |
65 | ```bash
66 | npm run deploy
67 | ```
68 |
69 | Note: if you don't set the environment variables, you may get an error when deploying the project.
--------------------------------------------------------------------------------
/examples/cloudflare-pages/app/api/route.ts:
--------------------------------------------------------------------------------
1 | export const runtime = 'edge';
2 |
3 | export const dynamic = 'force-dynamic';
4 |
5 | import { Ratelimit } from "@upstash/ratelimit";
6 | import { Redis } from "@upstash/redis";
7 |
8 | // Create a new ratelimiter
9 | const ratelimit = new Ratelimit({
10 | redis: Redis.fromEnv(),
11 | limiter: Ratelimit.slidingWindow(10, "10 s"),
12 | prefix: "@upstash/ratelimit",
13 | });
14 |
15 | export async function GET(request: Request) {
16 |
17 | const identifier = "api";
18 | const { success, limit, remaining } = await ratelimit.limit(identifier);
19 | const response = {
20 | success: success,
21 | limit: limit,
22 | remaining: remaining
23 | }
24 |
25 | if (!success) {
26 | return new Response(JSON.stringify(response), { status: 429 });
27 | }
28 | return new Response(JSON.stringify(response));
29 | }
--------------------------------------------------------------------------------
/examples/cloudflare-pages/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/upstash/ratelimit-js/a879eff5a9340ea3a523aa624ba318d40c0d3615/examples/cloudflare-pages/app/favicon.ico
--------------------------------------------------------------------------------
/examples/cloudflare-pages/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | --foreground-rgb: 0, 0, 0;
7 | --background-start-rgb: 214, 219, 220;
8 | --background-end-rgb: 255, 255, 255;
9 | }
10 |
11 | @media (prefers-color-scheme: dark) {
12 | :root {
13 | --foreground-rgb: 255, 255, 255;
14 | --background-start-rgb: 0, 0, 0;
15 | --background-end-rgb: 0, 0, 0;
16 | }
17 | }
18 |
19 | body {
20 | color: rgb(var(--foreground-rgb));
21 | background: linear-gradient(
22 | to bottom,
23 | transparent,
24 | rgb(var(--background-end-rgb))
25 | )
26 | rgb(var(--background-start-rgb));
27 | }
28 |
29 | @layer utilities {
30 | .text-balance {
31 | text-wrap: balance;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/examples/cloudflare-pages/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Inter } from "next/font/google";
3 | import "./globals.css";
4 |
5 | const inter = Inter({ subsets: ["latin"] });
6 |
7 | export const metadata: Metadata = {
8 | title: "Create Next App",
9 | description: "Generated by create next app",
10 | };
11 |
12 | export default function RootLayout({
13 | children,
14 | }: Readonly<{
15 | children: React.ReactNode;
16 | }>) {
17 | return (
18 |
19 |
{children}
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/examples/cloudflare-pages/app/not-found.tsx:
--------------------------------------------------------------------------------
1 | export const runtime = "edge";
2 |
3 | export default function NotFound() {
4 | return (
5 | <>
6 | 404: This page could not be found.
7 |
8 |
9 |
14 |
15 | 404
16 |
17 |
18 |
This page could not be found.
19 |
20 |
21 |
22 | >
23 | );
24 | }
25 |
26 | const styles = {
27 | error: {
28 | fontFamily:
29 | 'system-ui,"Segoe UI",Roboto,Helvetica,Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji"',
30 | height: "100vh",
31 | textAlign: "center",
32 | display: "flex",
33 | flexDirection: "column",
34 | alignItems: "center",
35 | justifyContent: "center",
36 | },
37 |
38 | desc: {
39 | display: "inline-block",
40 | },
41 |
42 | h1: {
43 | display: "inline-block",
44 | margin: "0 20px 0 0",
45 | padding: "0 23px 0 0",
46 | fontSize: 24,
47 | fontWeight: 500,
48 | verticalAlign: "top",
49 | lineHeight: "49px",
50 | },
51 |
52 | h2: {
53 | fontSize: 14,
54 | fontWeight: 400,
55 | lineHeight: "49px",
56 | margin: 0,
57 | },
58 | } as const;
59 |
--------------------------------------------------------------------------------
/examples/cloudflare-pages/app/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import React, { useState } from "react";
3 |
4 | export default function Home() {
5 | const [status, setStatus] = useState({
6 | success: true,
7 | limit: 10,
8 | remaining: 10
9 | });
10 |
11 | const handleClick = async () => {
12 | try {
13 | const response = await fetch(`/api`);
14 | const data = await response.text();
15 | setStatus(JSON.parse(data));
16 | } catch (error) {
17 | console.error("Error fetching data:", error);
18 | setStatus({
19 | success: false,
20 | limit: -1,
21 | remaining: -1
22 | });
23 | }
24 | };
25 |
26 | return (
27 |
28 |
29 |
This app is an example of rate limiting an API on Vercel Edge.
30 |
Click the button below to call the API and get the rate limit status.
31 |
32 |
33 | {Object.entries(status).map(([key, value]) => (
34 |
35 |
{key}
36 |
{JSON.stringify(value)}
37 |
38 | ))}
39 |
40 |
41 |
44 |
45 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/examples/cloudflare-pages/env.d.ts:
--------------------------------------------------------------------------------
1 | // Generated by Wrangler
2 | // by running `wrangler types --env-interface CloudflareEnv env.d.ts`
3 |
4 | interface CloudflareEnv {
5 | }
6 |
--------------------------------------------------------------------------------
/examples/cloudflare-pages/next.config.mjs:
--------------------------------------------------------------------------------
1 | import { setupDevPlatform } from '@cloudflare/next-on-pages/next-dev';
2 |
3 | // Here we use the @cloudflare/next-on-pages next-dev module to allow us to use bindings during local development
4 | // (when running the application with `next dev`), for more information see:
5 | // https://github.com/cloudflare/next-on-pages/blob/5712c57ea7/internal-packages/next-dev/README.md
6 | if (process.env.NODE_ENV === 'development') {
7 | await setupDevPlatform();
8 | }
9 |
10 | /** @type {import('next').NextConfig} */
11 | const nextConfig = {};
12 |
13 | export default nextConfig;
14 |
--------------------------------------------------------------------------------
/examples/cloudflare-pages/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "cloudflare-pages",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint",
10 | "pages:build": "npx @cloudflare/next-on-pages",
11 | "preview": "npm run pages:build && wrangler pages dev",
12 | "deploy": "npm run pages:build && wrangler pages deploy",
13 | "cf-typegen": "wrangler types --env-interface CloudflareEnv env.d.ts"
14 | },
15 | "dependencies": {
16 | "@upstash/ratelimit": "^1.1.3",
17 | "next": "14.1.0",
18 | "react": "^18",
19 | "react-dom": "^18"
20 | },
21 | "devDependencies": {
22 | "@cloudflare/next-on-pages": "^1.11.2",
23 | "@cloudflare/workers-types": "^4.20240502.0",
24 | "@types/node": "^20",
25 | "@types/react": "^18",
26 | "@types/react-dom": "^18",
27 | "autoprefixer": "^10.0.1",
28 | "eslint": "^8",
29 | "eslint-config-next": "14.1.0",
30 | "eslint-plugin-next-on-pages": "^1.11.2",
31 | "postcss": "^8",
32 | "tailwindcss": "^3.3.0",
33 | "typescript": "^5",
34 | "vercel": "^34.1.8",
35 | "wrangler": "^3.53.1"
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/examples/cloudflare-pages/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/examples/cloudflare-pages/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/cloudflare-pages/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/cloudflare-pages/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config: Config = {
4 | content: [
5 | "./pages/**/*.{js,ts,jsx,tsx,mdx}",
6 | "./components/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./app/**/*.{js,ts,jsx,tsx,mdx}",
8 | ],
9 | theme: {
10 | extend: {
11 | backgroundImage: {
12 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
13 | "gradient-conic":
14 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
15 | },
16 | },
17 | },
18 | plugins: [],
19 | };
20 | export default config;
21 |
--------------------------------------------------------------------------------
/examples/cloudflare-pages/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./*"]
22 | },
23 | "types": [
24 | "@cloudflare/workers-types/2023-07-01"
25 | ]
26 | },
27 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
28 | "exclude": ["node_modules"]
29 | }
30 |
--------------------------------------------------------------------------------
/examples/cloudflare-pages/wrangler.toml:
--------------------------------------------------------------------------------
1 | #:schema node_modules/wrangler/config-schema.json
2 | name = "cloudflare-pages"
3 | compatibility_date = "2024-05-02"
4 | compatibility_flags = ["nodejs_compat"]
5 | pages_build_output_dir = ".vercel/output/static"
6 |
7 | # Automatically place your workloads in an optimal location to minimize latency.
8 | # If you are running back-end logic in a Pages Function, running it closer to your back-end infrastructure
9 | # rather than the end user may result in better performance.
10 | # Docs: https://developers.cloudflare.com/pages/functions/smart-placement/#smart-placement
11 | # [placement]
12 | # mode = "smart"
13 |
14 | # Variable bindings. These are arbitrary, plaintext strings (similar to environment variables)
15 | # Docs:
16 | # - https://developers.cloudflare.com/pages/functions/bindings/#environment-variables
17 | # Note: Use secrets to store sensitive data.
18 | # - https://developers.cloudflare.com/pages/functions/bindings/#secrets
19 | # [vars]
20 | # MY_VARIABLE = "production_value"
21 |
22 | # Bind the Workers AI model catalog. Run machine learning models, powered by serverless GPUs, on Cloudflare’s global network
23 | # Docs: https://developers.cloudflare.com/pages/functions/bindings/#workers-ai
24 | # [ai]
25 | # binding = "AI"
26 |
27 | # Bind a D1 database. D1 is Cloudflare’s native serverless SQL database.
28 | # Docs: https://developers.cloudflare.com/pages/functions/bindings/#d1-databases
29 | # [[d1_databases]]
30 | # binding = "MY_DB"
31 | # database_name = "my-database"
32 | # database_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
33 |
34 | # Bind a Durable Object. Durable objects are a scale-to-zero compute primitive based on the actor model.
35 | # Durable Objects can live for as long as needed. Use these when you need a long-running "server", such as in realtime apps.
36 | # Docs: https://developers.cloudflare.com/workers/runtime-apis/durable-objects
37 | # [[durable_objects.bindings]]
38 | # name = "MY_DURABLE_OBJECT"
39 | # class_name = "MyDurableObject"
40 | # script_name = 'my-durable-object'
41 |
42 | # Bind a KV Namespace. Use KV as persistent storage for small key-value pairs.
43 | # Docs: https://developers.cloudflare.com/pages/functions/bindings/#kv-namespaces
44 | # KV Example:
45 | # [[kv_namespaces]]
46 | # binding = "MY_KV_NAMESPACE"
47 | # id = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
48 |
49 | # Bind a Queue producer. Use this binding to schedule an arbitrary task that may be processed later by a Queue consumer.
50 | # Docs: https://developers.cloudflare.com/pages/functions/bindings/#queue-producers
51 | # [[queues.producers]]
52 | # binding = "MY_QUEUE"
53 | # queue = "my-queue"
54 |
55 | # Bind an R2 Bucket. Use R2 to store arbitrarily large blobs of data, such as files.
56 | # Docs: https://developers.cloudflare.com/pages/functions/bindings/#r2-buckets
57 | # [[r2_buckets]]
58 | # binding = "MY_BUCKET"
59 | # bucket_name = "my-bucket"
60 |
61 | # Bind another Worker service. Use this binding to call another Worker without network overhead.
62 | # Docs: https://developers.cloudflare.com/pages/functions/bindings/#service-bindings
63 | # [[services]]
64 | # binding = "MY_SERVICE"
65 | # service = "my-service"
66 |
67 | # To use different bindings for preview and production environments, follow the examples below.
68 | # When using environment-specific overrides for bindings, ALL bindings must be specified on a per-environment basis.
69 | # Docs: https://developers.cloudflare.com/pages/functions/wrangler-configuration#environment-specific-overrides
70 |
71 | ######## PREVIEW environment config ########
72 |
73 | # [env.preview.vars]
74 | # API_KEY = "xyz789"
75 |
76 | # [[env.preview.kv_namespaces]]
77 | # binding = "MY_KV_NAMESPACE"
78 | # id = ""
79 |
80 | ######## PRODUCTION environment config ########
81 |
82 | # [env.production.vars]
83 | # API_KEY = "abc123"
84 |
85 | # [[env.production.kv_namespaces]]
86 | # binding = "MY_KV_NAMESPACE"
87 | # id = ""
88 |
--------------------------------------------------------------------------------
/examples/cloudflare-workers/.gitignore:
--------------------------------------------------------------------------------
1 | # Based on https://raw.githubusercontent.com/github/gitignore/main/Node.gitignore
2 |
3 | # Logs
4 |
5 | logs
6 | _.log
7 | npm-debug.log_
8 | yarn-debug.log*
9 | yarn-error.log*
10 | lerna-debug.log*
11 | .pnpm-debug.log*
12 |
13 | # Diagnostic reports (https://nodejs.org/api/report.html)
14 |
15 | report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
16 |
17 | # Runtime data
18 |
19 | pids
20 | _.pid
21 | _.seed
22 | \*.pid.lock
23 |
24 | # Directory for instrumented libs generated by jscoverage/JSCover
25 |
26 | lib-cov
27 |
28 | # Coverage directory used by tools like istanbul
29 |
30 | coverage
31 | \*.lcov
32 |
33 | # nyc test coverage
34 |
35 | .nyc_output
36 |
37 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
38 |
39 | .grunt
40 |
41 | # Bower dependency directory (https://bower.io/)
42 |
43 | bower_components
44 |
45 | # node-waf configuration
46 |
47 | .lock-wscript
48 |
49 | # Compiled binary addons (https://nodejs.org/api/addons.html)
50 |
51 | build/Release
52 |
53 | # Dependency directories
54 |
55 | node_modules/
56 | jspm_packages/
57 |
58 | # Snowpack dependency directory (https://snowpack.dev/)
59 |
60 | web_modules/
61 |
62 | # TypeScript cache
63 |
64 | \*.tsbuildinfo
65 |
66 | # Optional npm cache directory
67 |
68 | .npm
69 |
70 | # Optional eslint cache
71 |
72 | .eslintcache
73 |
74 | # Optional stylelint cache
75 |
76 | .stylelintcache
77 |
78 | # Microbundle cache
79 |
80 | .rpt2_cache/
81 | .rts2_cache_cjs/
82 | .rts2_cache_es/
83 | .rts2_cache_umd/
84 |
85 | # Optional REPL history
86 |
87 | .node_repl_history
88 |
89 | # Output of 'npm pack'
90 |
91 | \*.tgz
92 |
93 | # Yarn Integrity file
94 |
95 | .yarn-integrity
96 |
97 | # dotenv environment variable files
98 |
99 | .env
100 | .env.development.local
101 | .env.test.local
102 | .env.production.local
103 | .env.local
104 |
105 | # parcel-bundler cache (https://parceljs.org/)
106 |
107 | .cache
108 | .parcel-cache
109 |
110 | # Next.js build output
111 |
112 | .next
113 | out
114 |
115 | # Nuxt.js build / generate output
116 |
117 | .nuxt
118 | dist
119 |
120 | # Gatsby files
121 |
122 | .cache/
123 |
124 | # Comment in the public line in if your project uses Gatsby and not Next.js
125 |
126 | # https://nextjs.org/blog/next-9-1#public-directory-support
127 |
128 | # public
129 |
130 | # vuepress build output
131 |
132 | .vuepress/dist
133 |
134 | # vuepress v2.x temp and cache directory
135 |
136 | .temp
137 | .cache
138 |
139 | # Docusaurus cache and generated files
140 |
141 | .docusaurus
142 |
143 | # Serverless directories
144 |
145 | .serverless/
146 |
147 | # FuseBox cache
148 |
149 | .fusebox/
150 |
151 | # DynamoDB Local files
152 |
153 | .dynamodb/
154 |
155 | # TernJS port file
156 |
157 | .tern-port
158 |
159 | # Stores VSCode versions used for testing VSCode extensions
160 |
161 | .vscode-test
162 |
163 | # yarn v2
164 |
165 | .yarn/cache
166 | .yarn/unplugged
167 | .yarn/build-state.yml
168 | .yarn/install-state.gz
169 | .pnp.\*
170 |
171 | # IntelliJ based IDEs
172 | .idea
173 |
174 | # Finder (MacOS) folder config
175 | .DS_Store
176 |
177 |
--------------------------------------------------------------------------------
/examples/cloudflare-workers/README.md:
--------------------------------------------------------------------------------
1 |
2 | ## Local Development
3 |
4 | For testing the app locally, start by creating an Upstash Redis. Next, create the `.dev.vars` file under `cloudflare-workers` directory and set the `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` environment variables:
5 |
6 | ```
7 | // .dev.vars
8 | UPSTASH_REDIS_REST_URL="****"
9 | UPSTASH_REDIS_REST_TOKEN="****"
10 | ```
11 |
12 | Then, simply run:
13 |
14 | ```
15 | npx wrangler dev
16 | ```
17 |
18 | ## Deploy on Cloudflare
19 |
20 | To deploy the app, set the environment variables with the following lines. In both cases, you will be prompted to enter the secret value:
21 |
22 | ```
23 | npx wrangler secret put UPSTASH_REDIS_REST_URL
24 | npx wrangler secret put UPSTASH_REDIS_REST_TOKEN
25 | ```
26 |
27 | Then, deploy the project with:
28 |
29 | ```
30 | npx wrangler deploy
31 | ```
--------------------------------------------------------------------------------
/examples/cloudflare-workers/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/upstash/ratelimit-js/a879eff5a9340ea3a523aa624ba318d40c0d3615/examples/cloudflare-workers/bun.lockb
--------------------------------------------------------------------------------
/examples/cloudflare-workers/ci.test.ts:
--------------------------------------------------------------------------------
1 | import { test, expect, } from "bun:test";
2 |
3 |
4 | const deploymentURL = process.env.DEPLOYMENT_URL ?? "http://127.0.0.1:8787";
5 | if (!deploymentURL) {
6 | throw new Error("DEPLOYMENT_URL not set");
7 | }
8 |
9 | test("the server is running", async () => {
10 | const res = await fetch(`${deploymentURL}/limit`);
11 |
12 | if (res.status !== 200) {
13 | console.log(await res.text());
14 | }
15 | expect(res.status).toEqual(200);
16 | }, { timeout: 10000 });
--------------------------------------------------------------------------------
/examples/cloudflare-workers/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "cloudflare-workers",
3 | "version": "0.0.0",
4 | "devDependencies": {
5 | "@cloudflare/workers-types": "^4.20230419.0",
6 | "@types/bun": "^1.1.10",
7 | "bun-types": "^1.1.29",
8 | "typescript": "^5.0.4",
9 | "wrangler": "^3.49.0"
10 | },
11 | "private": true,
12 | "scripts": {
13 | "dev": "wrangler dev",
14 | "deploy": "wrangler publish"
15 | },
16 | "dependencies": {
17 | "@upstash/ratelimit": "latest",
18 | "@upstash/redis": "latest"
19 | }
20 | }
--------------------------------------------------------------------------------
/examples/cloudflare-workers/src/index.ts:
--------------------------------------------------------------------------------
1 | import { Ratelimit } from "@upstash/ratelimit";
2 | import { Redis } from "@upstash/redis/cloudflare";
3 | export interface Env {
4 | UPSTASH_REDIS_REST_URL: string;
5 | UPSTASH_REDIS_REST_TOKEN: string;
6 | }
7 |
8 | const cache = new Map();
9 |
10 | export default {
11 | async fetch(request: Request, env: Env, context: ExecutionContext): Promise {
12 | try {
13 | console.log("URL:", env.UPSTASH_REDIS_REST_URL);
14 |
15 | if (new URL(request.url).pathname !== "/limit") {
16 | return new Response("go to /limit", { status: 400 });
17 | }
18 |
19 | const ratelimit = new Ratelimit({
20 | redis: Redis.fromEnv(env),
21 | limiter: Ratelimit.cachedFixedWindow(5, "5 s"),
22 | ephemeralCache: cache,
23 | analytics: true
24 | });
25 |
26 | const res = await ratelimit.limit("identifier");
27 | context.waitUntil(res.pending)
28 | if (res.success) {
29 | return new Response(JSON.stringify(res, null, 2), { status: 200 });
30 | } else {
31 | return new Response(JSON.stringify({ res }, null, 2), { status: 429 });
32 | }
33 | } catch (err) {
34 | return new Response((err as Error).message, { status: 500 });
35 | }
36 | },
37 | };
38 |
--------------------------------------------------------------------------------
/examples/cloudflare-workers/wrangler.toml:
--------------------------------------------------------------------------------
1 | name = "upstash-ratelimit"
2 | main = "src/index.ts"
3 | compatibility_date = "2022-08-11"
4 |
--------------------------------------------------------------------------------
/examples/deno/README.md:
--------------------------------------------------------------------------------
1 |
2 | ## Deno Examples
3 |
4 | This directory has two deno examples.
5 |
6 | | File | Description |
7 | | --------------- | ----------- |
8 | | `deprecated.ts` | Deno app with the `serve` method which was deprecated with Deno version `0.107.0`. |
9 | | `main.ts` | Up-to-date Deno app with the `Deno.serve` method |
10 |
11 | To run the apps locally, simply set the environment variables `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` and run:
12 |
13 | ```
14 | deno run --allow-net --allow-env main.ts
15 | ```
--------------------------------------------------------------------------------
/examples/deno/deprecated.ts:
--------------------------------------------------------------------------------
1 | import { serve } from "https://deno.land/std@0.106.0/http/server.ts";
2 | import { Ratelimit } from "https://cdn.skypack.dev/@upstash/ratelimit@latest";
3 | import { Redis } from "https://esm.sh/@upstash/redis";
4 |
5 | const server = serve({ port: 8000 });
6 |
7 | // Create a new ratelimiter, allowing 10 requests per 10 seconds
8 | const ratelimit = new Ratelimit({
9 | redis: Redis.fromEnv(),
10 | limiter: Ratelimit.slidingWindow(10, "10s"),
11 | analytics: true,
12 | prefix: "@upstash/ratelimit",
13 | });
14 |
15 | console.log("Server running...");
16 |
17 | for await (const req of server) {
18 |
19 | if (req.url !== "/") {
20 | continue
21 | }
22 |
23 | // Use a constant string to limit all requests with a single ratelimit
24 | // You can also use a userID, apiKey, or IP address for individual limits.
25 | const identifier = "api";
26 |
27 | const { success, remaining } = await ratelimit.limit(identifier);
28 | if (!success) {
29 | req.respond({ status: 429, body: "Too Many Requests" });
30 | continue;
31 | }
32 |
33 | // Perform your expensive calculation here
34 | const body = `Here you go! (Remaining" ${remaining})`;
35 | req.respond({ body });
36 | }
37 |
--------------------------------------------------------------------------------
/examples/deno/main.ts:
--------------------------------------------------------------------------------
1 | import { Ratelimit } from "https://cdn.skypack.dev/@upstash/ratelimit@latest";
2 | import { Redis } from "https://esm.sh/@upstash/redis";
3 |
4 | // Create a new ratelimiter, allowing 10 requests per 10 seconds
5 | const ratelimit = new Ratelimit({
6 | redis: Redis.fromEnv(),
7 | limiter: Ratelimit.slidingWindow(10, "10s"),
8 | analytics: true,
9 | prefix: "@upstash/ratelimit",
10 | });
11 |
12 | async function requestHandler(request: Request): Promise {
13 |
14 | // Use a constant string to limit all requests with a single ratelimit
15 | // You can also use a userID, apiKey, or IP address for individual limits.
16 | const identifier = "api";
17 |
18 | const { success, remaining } = await ratelimit.limit(identifier);
19 | if (!success) {
20 | return new Response("Too Many Requests", { status: 429 });
21 | }
22 |
23 | // Perform your expensive calculation here
24 | const body = `Here you go! (Remaining" ${remaining})`;
25 | return new Response(body, { status: 200 });
26 | }
27 |
28 | Deno.serve(requestHandler, { port: 8000 });
29 |
--------------------------------------------------------------------------------
/examples/enable-protection/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/examples/enable-protection/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/examples/enable-protection/README.md:
--------------------------------------------------------------------------------
1 | # Using deny list by enabling protection
2 |
3 | To use enable list, simply set the `enableProtection` parameter to true when
4 | initializing the Upstash Redis client.
5 |
6 | ```tsx
7 | const ratelimit = new Ratelimit({
8 | redis: Redis.fromEnv(),
9 | limiter: Ratelimit.slidingWindow(10, "10 s"),
10 | prefix: "@upstash/ratelimit",
11 | analytics: true,
12 | enableProtection: true
13 | });
14 | ```
15 |
16 | When this parameter is enabled, redis client will check the identifier and
17 | other parameters against a dent list managed through [Ratelimit Dashboard](https://console.upstash.com/ratelimit).
18 |
19 | When analytics is set to true, requests blocked with the deny list are
20 | logged under Denied and shown in the dashboard.
21 |
22 | In addition to passing an identifier, you can pass IP address, user agent
23 | and country in the `limit` method. If any of these values is in the deny
24 | list, the request will be denied.
25 |
26 | ```tsx
27 | const { success, limit, remaining, pending, reason } = await ratelimit.limit(
28 | identifier, {
29 | ip: ipAddress,
30 | userAgent: userAgent,
31 | country: country
32 | }
33 | );
34 | ```
35 |
36 | With this change, we also introduce the reason parameter, which denotes
37 | whether a request passed with timeout or rejected with caching or deny list.
38 |
39 | # Running the example locally
40 |
41 | To run the example, simply create a Redis instance from Upstash
42 | console and save the URL and the token to this directory under a
43 | `.env` file.
44 |
45 | Then run `npm run dev`.
46 |
--------------------------------------------------------------------------------
/examples/enable-protection/app/api/route.ts:
--------------------------------------------------------------------------------
1 | export const runtime = 'edge';
2 |
3 | export const dynamic = 'force-dynamic';
4 |
5 | import { waitUntil } from '@vercel/functions';
6 | import { Ratelimit } from "@upstash/ratelimit";
7 | import { Redis } from "@upstash/redis";
8 |
9 | // Create a new ratelimiter
10 | const ratelimit = new Ratelimit({
11 | redis: Redis.fromEnv(),
12 | limiter: Ratelimit.slidingWindow(10, "10 s"),
13 | prefix: "@upstash/ratelimit",
14 | analytics: true,
15 | enableProtection: true
16 | });
17 |
18 | export async function POST(request: Request) {
19 |
20 | const content = await request.json()
21 |
22 | const { success, limit, remaining, pending, reason } = await ratelimit.limit(
23 | content, {ip: "10"});
24 | const response = {
25 | success: success,
26 | limit: limit,
27 | remaining: remaining
28 | }
29 | console.log(success, reason)
30 |
31 | // pending is a promise for handling the analytics submission
32 | waitUntil(pending)
33 |
34 | if (!success) {
35 | return new Response(JSON.stringify(response), { status: 429 });
36 | }
37 | return new Response(JSON.stringify(response));
38 | }
--------------------------------------------------------------------------------
/examples/enable-protection/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/upstash/ratelimit-js/a879eff5a9340ea3a523aa624ba318d40c0d3615/examples/enable-protection/app/favicon.ico
--------------------------------------------------------------------------------
/examples/enable-protection/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | --foreground-rgb: 0, 0, 0;
7 | --background-start-rgb: 249,250,250;
8 | --background-end-rgb: 236,238,238;
9 | }
10 |
11 | @media (prefers-color-scheme: dark) {
12 | :root {
13 | --foreground-rgb: 255, 255, 255;
14 | --background-start-rgb: 0, 0, 0;
15 | --background-end-rgb: 0, 0, 0;
16 | }
17 | }
18 |
19 | body {
20 | color: rgb(var(--foreground-rgb));
21 | background: linear-gradient(
22 | to bottom,
23 | transparent,
24 | rgb(var(--background-end-rgb))
25 | )
26 | rgb(var(--background-start-rgb));
27 | }
28 |
29 | @layer utilities {
30 | .text-balance {
31 | text-wrap: balance;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/examples/enable-protection/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Inter } from "next/font/google";
3 | import "./globals.css";
4 |
5 | const inter = Inter({ subsets: ["latin"] });
6 |
7 | export const metadata: Metadata = {
8 | title: "Create Next App",
9 | description: "Generated by create next app",
10 | };
11 |
12 | export default function RootLayout({
13 | children,
14 | }: Readonly<{
15 | children: React.ReactNode;
16 | }>) {
17 | return (
18 |
19 | {children}
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/examples/enable-protection/app/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import React, { useState } from "react";
3 |
4 | export default function Home() {
5 | const [status, setStatus] = useState({
6 | success: true,
7 | limit: 10,
8 | remaining: 10
9 | });
10 | const [message, setMessage] = useState("");
11 | const [identifier, setIdentifier] = useState("userId");
12 |
13 | const handleClick = async () => {
14 | try {
15 | const response = await fetch(`/api`, {body: JSON.stringify(identifier), method: "POST"});
16 | const data = await response.text();
17 | setStatus(JSON.parse(data));
18 | } catch (error) {
19 | console.error("Error fetching data:", error);
20 | setStatus({
21 | success: false,
22 | limit: -1,
23 | remaining: -1
24 | });
25 | setMessage(`Error fetching data. Make sure that env variables are set as explained in the README file.`,)
26 | }
27 | };
28 | const handleFormSubmit = (identifier: string) => {
29 | console.log('Form submitted with identifier:', identifier);
30 | // Add your logic to handle the form submission
31 | };
32 |
33 | return (
34 |
35 |
36 |
This app is an example of rate limiting an API on Vercel Edge.
37 |
Click the button below to call the API and get the rate limit status.
38 |
{message}
39 |
40 |
41 |
42 | {Object.entries(status).map(([key, value]) => (
43 |
44 |
{key}
45 |
{JSON.stringify(value)}
46 |
47 | ))}
48 |
49 |
50 |
63 |
66 |
67 |
68 | );
69 | }
70 |
--------------------------------------------------------------------------------
/examples/enable-protection/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {};
3 |
4 | export default nextConfig;
5 |
--------------------------------------------------------------------------------
/examples/enable-protection/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vercel-edge",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@upstash/ratelimit": "^1.2.1",
13 | "@vercel/functions": "^1.0.2",
14 | "next": "14.2.23",
15 | "react": "^18",
16 | "react-dom": "^18"
17 | },
18 | "devDependencies": {
19 | "@types/node": "^20",
20 | "@types/react": "^18",
21 | "@types/react-dom": "^18",
22 | "eslint": "^8",
23 | "eslint-config-next": "14.2.3",
24 | "postcss": "^8",
25 | "tailwindcss": "^3.4.1",
26 | "typescript": "^5"
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/examples/enable-protection/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/examples/enable-protection/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/enable-protection/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/enable-protection/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config: Config = {
4 | content: [
5 | "./pages/**/*.{js,ts,jsx,tsx,mdx}",
6 | "./components/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./app/**/*.{js,ts,jsx,tsx,mdx}",
8 | ],
9 | theme: {
10 | extend: {
11 | backgroundImage: {
12 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
13 | "gradient-conic":
14 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
15 | },
16 | },
17 | },
18 | plugins: [],
19 | };
20 | export default config;
21 |
--------------------------------------------------------------------------------
/examples/enable-protection/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./*"]
22 | }
23 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------
/examples/nextjs-middleware/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/examples/nextjs-middleware/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/examples/nextjs-middleware/README.md:
--------------------------------------------------------------------------------
1 | # Nextjs Example with Middleware
2 |
3 | In this example, we set up rate limiting for an API endpoint in a Nextjs project using the middleware.
4 |
5 | We define two api endpoints `/api` and `api/blocked`. First one simply returns `Voila!` while the second one returns `Rate Limited!` with status 429. In the middleware, we initialize a Ratelimit and use it to rate limit requests made to `/api` endpoint. If the request is rate limited, we redirect to the `api/blocked` endpoint:
6 |
7 | ```ts
8 | import { Ratelimit } from "@upstash/ratelimit";
9 | import { Redis } from "@upstash/redis";
10 | import { type NextFetchEvent, type NextRequest, NextResponse } from "next/server";
11 |
12 | const ratelimit = new Ratelimit({
13 | redis: Redis.fromEnv(),
14 | limiter: Ratelimit.fixedWindow(10, "10s"),
15 | ephemeralCache: new Map(),
16 | prefix: "@upstash/ratelimit",
17 | analytics: true,
18 | });
19 |
20 | export default async function middleware(
21 | request: NextRequest,
22 | context: NextFetchEvent,
23 | ): Promise {
24 | const ip = request.ip ?? "127.0.0.1";
25 |
26 | const { success, pending, limit, remaining } = await ratelimit.limit(ip);
27 | // we use context.waitUntil since analytics: true.
28 | // see https://upstash.com/docs/oss/sdks/ts/ratelimit/gettingstarted#serverless-environments
29 | context.waitUntil(pending);
30 |
31 | const res = success
32 | ? NextResponse.next()
33 | : NextResponse.redirect(new URL("/api/blocked", request.url));
34 |
35 | res.headers.set("X-RateLimit-Success", success.toString());
36 | res.headers.set("X-RateLimit-Limit", limit.toString());
37 | res.headers.set("X-RateLimit-Remaining", remaining.toString());
38 |
39 | return res;
40 | }
41 |
42 | export const config = {
43 | matcher: "/api",
44 | };
45 | ```
46 |
47 | # Run locally
48 |
49 | To run the example in your local environment, create a Upstash Redis and set the `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` environment variables. Then run
50 |
51 | ```bash
52 | npm run dev
53 | ```
54 |
55 | # Deploy to Vercel
56 |
57 | To deploy the project, install [Vercel CLI](https://vercel.com/docs/cli), set the environment variables `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` on Vercel and run:
58 |
59 | ```bash
60 | vercel deploy
61 | ```
--------------------------------------------------------------------------------
/examples/nextjs-middleware/app/api/blocked/route.tsx:
--------------------------------------------------------------------------------
1 | export const runtime = 'nodejs';
2 |
3 | export const dynamic = 'force-dynamic';
4 |
5 | export async function GET(request: Request) {
6 | return new Response("Rate Limited!", {status: 429});
7 | }
--------------------------------------------------------------------------------
/examples/nextjs-middleware/app/api/route.ts:
--------------------------------------------------------------------------------
1 | export const runtime = 'nodejs';
2 |
3 | export const dynamic = 'force-dynamic';
4 |
5 | export async function GET(request: Request) {
6 |
7 | return new Response("Voila!");
8 | }
--------------------------------------------------------------------------------
/examples/nextjs-middleware/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/upstash/ratelimit-js/a879eff5a9340ea3a523aa624ba318d40c0d3615/examples/nextjs-middleware/app/favicon.ico
--------------------------------------------------------------------------------
/examples/nextjs-middleware/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | --foreground-rgb: 0, 0, 0;
7 | --background-start-rgb: 214, 219, 220;
8 | --background-end-rgb: 255, 255, 255;
9 | }
10 |
11 | @media (prefers-color-scheme: dark) {
12 | :root {
13 | --foreground-rgb: 255, 255, 255;
14 | --background-start-rgb: 0, 0, 0;
15 | --background-end-rgb: 0, 0, 0;
16 | }
17 | }
18 |
19 | body {
20 | color: rgb(var(--foreground-rgb));
21 | background: linear-gradient(
22 | to bottom,
23 | transparent,
24 | rgb(var(--background-end-rgb))
25 | )
26 | rgb(var(--background-start-rgb));
27 | }
28 |
29 | @layer utilities {
30 | .text-balance {
31 | text-wrap: balance;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/examples/nextjs-middleware/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Inter } from "next/font/google";
3 | import "./globals.css";
4 |
5 | const inter = Inter({ subsets: ["latin"] });
6 |
7 | export const metadata: Metadata = {
8 | title: "Create Next App",
9 | description: "Generated by create next app",
10 | };
11 |
12 | export default function RootLayout({
13 | children,
14 | }: Readonly<{
15 | children: React.ReactNode;
16 | }>) {
17 | return (
18 |
19 | {children}
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/examples/nextjs-middleware/app/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import React, { useState } from "react";
3 |
4 | export default function Home() {
5 | const [status, setStatus] = useState({
6 | success: true,
7 | limit: 10,
8 | remaining: 10
9 | });
10 | const [message, setMessage] = useState("")
11 |
12 | const handleClick = async () => {
13 | try {
14 | const response = await fetch(`/api`);
15 |
16 | // handling the case when env variables are not set and
17 | // middleware fails
18 | if (response.status === 500) {
19 | throw Error(`API responded with ${response.status}!`)
20 | }
21 |
22 | if (response.status === 429) {
23 | setStatus({
24 | ...status,
25 | success: false
26 | })
27 | } else {
28 |
29 | setStatus({
30 | success: response.headers.get("X-RateLimit-Success") === "true",
31 | limit: +(response.headers.get("X-RateLimit-Limit") ?? -1),
32 | remaining: +(response.headers.get("X-RateLimit-Remaining") ?? -1),
33 | })
34 | }
35 |
36 | const content = await response.text()
37 | setMessage(content)
38 | } catch (error) {
39 | console.error("Error fetching data:", error);
40 | setStatus({
41 | success: false,
42 | limit: -1,
43 | remaining: -1
44 | })
45 | setMessage(`Error fetching data. Make sure that env variables are set as explained in the README file.`,)
46 | }
47 | };
48 |
49 | return (
50 |
51 |
52 |
This app is an example of rate limiting an API on Vercel Edge.
53 |
Click the button below to call the API and get the rate limit status.
54 |
{message}
55 |
56 |
57 | {status.success && Object.entries(status).map(([key, value]) => (
58 |
59 |
{key}
60 |
{JSON.stringify(value)}
61 |
62 | ))}
63 |
64 |
65 |
68 |
69 |
70 | );
71 | }
72 |
--------------------------------------------------------------------------------
/examples/nextjs-middleware/middleware.ts:
--------------------------------------------------------------------------------
1 | import { Ratelimit } from "@upstash/ratelimit";
2 | import { Redis } from "@upstash/redis";
3 | import { type NextFetchEvent, type NextRequest, NextResponse } from "next/server";
4 |
5 | const ratelimit = new Ratelimit({
6 | redis: Redis.fromEnv(),
7 | limiter: Ratelimit.fixedWindow(10, "10s"),
8 | ephemeralCache: new Map(),
9 | prefix: "@upstash/ratelimit",
10 | analytics: true,
11 | });
12 |
13 | export default async function middleware(
14 | request: NextRequest,
15 | context: NextFetchEvent,
16 | ): Promise {
17 | const ip = request.ip ?? "127.0.0.1";
18 |
19 | const { success, pending, limit, remaining } = await ratelimit.limit(ip);
20 | // we use context.waitUntil since analytics: true.
21 | // see https://upstash.com/docs/oss/sdks/ts/ratelimit/gettingstarted#serverless-environments
22 | context.waitUntil(pending);
23 |
24 | const res = success
25 | ? NextResponse.next()
26 | : NextResponse.redirect(new URL("/api/blocked", request.url));
27 |
28 | res.headers.set("X-RateLimit-Success", success.toString());
29 | res.headers.set("X-RateLimit-Limit", limit.toString());
30 | res.headers.set("X-RateLimit-Remaining", remaining.toString());
31 |
32 | return res;
33 | }
34 |
35 | export const config = {
36 | matcher: "/api",
37 | };
--------------------------------------------------------------------------------
/examples/nextjs-middleware/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {};
3 |
4 | export default nextConfig;
5 |
--------------------------------------------------------------------------------
/examples/nextjs-middleware/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nextjs-middleware",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@upstash/ratelimit": "^1.1.3",
13 | "next": "14.2.23",
14 | "react": "^18",
15 | "react-dom": "^18"
16 | },
17 | "devDependencies": {
18 | "@types/node": "^20",
19 | "@types/react": "^18",
20 | "@types/react-dom": "^18",
21 | "eslint": "^8",
22 | "eslint-config-next": "14.2.3",
23 | "postcss": "^8",
24 | "tailwindcss": "^3.4.1",
25 | "typescript": "^5"
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/examples/nextjs-middleware/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/examples/nextjs-middleware/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/nextjs-middleware/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/nextjs-middleware/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config: Config = {
4 | content: [
5 | "./pages/**/*.{js,ts,jsx,tsx,mdx}",
6 | "./components/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./app/**/*.{js,ts,jsx,tsx,mdx}",
8 | ],
9 | theme: {
10 | extend: {
11 | backgroundImage: {
12 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
13 | "gradient-conic":
14 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
15 | },
16 | },
17 | },
18 | plugins: [],
19 | };
20 | export default config;
21 |
--------------------------------------------------------------------------------
/examples/nextjs-middleware/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./*"]
22 | }
23 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------
/examples/nextjs/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/examples/nextjs/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/examples/nextjs/README.md:
--------------------------------------------------------------------------------
1 | # Nextjs Example
2 |
3 | In this example, we set up rate limiting for an API endpoint in a Nextjs project.
4 |
5 | We define the api in [`route.ts`](https://github.com/upstash/ratelimit/blob/main/examples/nextjs/app/api/route.ts), at `/api/route` route. We rate limit the requests using Upstash Ratelimit:
6 |
7 | ```ts
8 | export const runtime = 'nodejs';
9 |
10 | export const dynamic = 'force-dynamic';
11 |
12 | import { waitUntil } from '@vercel/functions';
13 | import { Ratelimit } from "@upstash/ratelimit";
14 | import { Redis } from "@upstash/redis";
15 |
16 | // Create a new ratelimiter
17 | const ratelimit = new Ratelimit({
18 | redis: Redis.fromEnv(),
19 | limiter: Ratelimit.slidingWindow(10, "10 s"),
20 | prefix: "@upstash/ratelimit",
21 | analytics: true
22 | });
23 |
24 | export async function GET(request: Request) {
25 |
26 | const identifier = "api";
27 | const { success, limit, remaining, pending } = await ratelimit.limit(identifier);
28 | const response = {
29 | success: success,
30 | limit: limit,
31 | remaining: remaining
32 | }
33 |
34 | // pending is a promise for handling the analytics submission
35 | waitUntil(pending)
36 |
37 | if (!success) {
38 | return new Response(JSON.stringify(response), { status: 429 });
39 | }
40 | return new Response(JSON.stringify(response));
41 | }
42 | ```
43 |
44 | The `redis` parameter denotes the Upstash Redis instance we use. The `limiter` parameter denotes the algorithm used to limit requests. The `prefix` parameter is used when creating a key for entries in the Redis, allowing us to use a single Redis instance for different rate limiters. The `analytics` parameter denotes whether analytics will we sent to the Redis in order to use the Upstash Analytics dashboard.
45 |
46 | To limit the requests, we call `ratelimit.limit` method with an identifier `"api"`. This identifier could be the ip address or the user id in your use case. See [our documentation](https://upstash.com/docs/oss/sdks/ts/ratelimit/methods#limit) for more information.
47 |
48 | There is also a pages router example in `pages/api/limit.ts`.
49 |
50 | # Run locally
51 |
52 | To run the example in your local environment, create a Upstash Redis and set the `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` environment variables. Then run
53 |
54 | ```bash
55 | npm run dev
56 | ```
57 |
58 | # Deploy to Vercel
59 |
60 | To deploy the project, install [Vercel CLI](https://vercel.com/docs/cli), set the environment variables `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` on Vercel and run:
61 |
62 | ```bash
63 | vercel deploy
64 | ```
--------------------------------------------------------------------------------
/examples/nextjs/app/api/route.ts:
--------------------------------------------------------------------------------
1 | export const runtime = 'nodejs';
2 |
3 | export const dynamic = 'force-dynamic';
4 |
5 | import { waitUntil } from '@vercel/functions';
6 | import { Ratelimit } from "@upstash/ratelimit";
7 | import { Redis } from "@upstash/redis";
8 |
9 | const redis = new Redis({
10 | url: process.env.UPSTASH_REDIS_REST_URL!,
11 | token: process.env.UPSTASH_REDIS_REST_TOKEN!,
12 | })
13 |
14 | // Create a new ratelimiter
15 | const ratelimit = new Ratelimit({
16 | redis,
17 | limiter: Ratelimit.slidingWindow(10, "10 s"),
18 | prefix: "@upstash/ratelimit",
19 | analytics: true
20 | });
21 |
22 | export async function GET(request: Request) {
23 |
24 | const identifier = "api";
25 | const { success, limit, remaining, pending } = await ratelimit.limit(identifier);
26 | const response = {
27 | success: success,
28 | limit: limit,
29 | remaining: remaining
30 | }
31 |
32 | // pending is a promise for handling the analytics submission
33 | waitUntil(pending)
34 |
35 | if (!success) {
36 | return new Response(JSON.stringify(response), { status: 429 });
37 | }
38 | return new Response(JSON.stringify(response));
39 | }
--------------------------------------------------------------------------------
/examples/nextjs/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/upstash/ratelimit-js/a879eff5a9340ea3a523aa624ba318d40c0d3615/examples/nextjs/app/favicon.ico
--------------------------------------------------------------------------------
/examples/nextjs/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | --foreground-rgb: 0, 0, 0;
7 | --background-start-rgb: 214, 219, 220;
8 | --background-end-rgb: 255, 255, 255;
9 | }
10 |
11 | @media (prefers-color-scheme: dark) {
12 | :root {
13 | --foreground-rgb: 255, 255, 255;
14 | --background-start-rgb: 0, 0, 0;
15 | --background-end-rgb: 0, 0, 0;
16 | }
17 | }
18 |
19 | body {
20 | color: rgb(var(--foreground-rgb));
21 | background: linear-gradient(
22 | to bottom,
23 | transparent,
24 | rgb(var(--background-end-rgb))
25 | )
26 | rgb(var(--background-start-rgb));
27 | }
28 |
29 | @layer utilities {
30 | .text-balance {
31 | text-wrap: balance;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/examples/nextjs/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Inter } from "next/font/google";
3 | import "./globals.css";
4 |
5 | const inter = Inter({ subsets: ["latin"] });
6 |
7 | export const metadata: Metadata = {
8 | title: "Create Next App",
9 | description: "Generated by create next app",
10 | };
11 |
12 | export default function RootLayout({
13 | children,
14 | }: Readonly<{
15 | children: React.ReactNode;
16 | }>) {
17 | return (
18 |
19 | {children}
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/examples/nextjs/app/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import React, { useState } from "react";
3 |
4 | export default function Home() {
5 | const [status, setStatus] = useState({
6 | success: true,
7 | limit: 10,
8 | remaining: 10
9 | });
10 | const [message, setMessage] = useState("")
11 |
12 | const handleClick = async () => {
13 | try {
14 | const response = await fetch(`/api`);
15 | const data = await response.text();
16 | setStatus(JSON.parse(data));
17 | } catch (error) {
18 | console.error("Error fetching data:", error);
19 | setStatus({
20 | success: false,
21 | limit: -1,
22 | remaining: -1
23 | });
24 | setMessage(`Error fetching data. Make sure that env variables are set as explained in the README file.`,)
25 | }
26 | };
27 |
28 | return (
29 |
30 |
31 |
This app is an example of rate limiting an API on Vercel Edge.
32 |
Click the button below to call the API and get the rate limit status.
33 |
{message}
34 |
35 |
36 | {Object.entries(status).map(([key, value]) => (
37 |
38 |
{key}
39 |
{JSON.stringify(value)}
40 |
41 | ))}
42 |
43 |
44 |
47 |
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/examples/nextjs/bun.lockb:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/upstash/ratelimit-js/a879eff5a9340ea3a523aa624ba318d40c0d3615/examples/nextjs/bun.lockb
--------------------------------------------------------------------------------
/examples/nextjs/ci.test.ts:
--------------------------------------------------------------------------------
1 | import { test, expect, } from "bun:test";
2 |
3 | const deploymentURL = process.env.DEPLOYMENT_URL ?? "http://127.0.0.1:3000";
4 | if (!deploymentURL) {
5 | throw new Error("DEPLOYMENT_URL not set");
6 | }
7 |
8 | test("the server is running", async () => {
9 | console.log(`${deploymentURL}/api`);
10 | const res = await fetch(`${deploymentURL}/api`);
11 |
12 | if (res.status !== 200) {
13 | console.log(await res.text());
14 | }
15 | expect(res.status).toEqual(200);
16 | }, { timeout: 10000 });
17 |
18 | test("the pages router example is working", async () => {
19 | console.log(`${deploymentURL}/api/pages-test`);
20 | const res = await fetch(`${deploymentURL}/api/pages-test`);
21 |
22 | if (res.status !== 200) {
23 | console.log(await res.text());
24 | }
25 | expect(res.status).toEqual(200);
26 | }, { timeout: 10000 });
--------------------------------------------------------------------------------
/examples/nextjs/next-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
4 | // NOTE: This file should not be edited
5 | // see https://nextjs.org/docs/basic-features/typescript for more information.
6 |
--------------------------------------------------------------------------------
/examples/nextjs/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {};
3 |
4 | export default nextConfig;
5 |
--------------------------------------------------------------------------------
/examples/nextjs/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "nextjs",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@upstash/ratelimit": "latest",
13 | "@upstash/redis": "latest",
14 | "@vercel/functions": "^1.0.2",
15 | "next": "14.2.23",
16 | "react": "^18",
17 | "react-dom": "^18"
18 | },
19 | "devDependencies": {
20 | "@types/bun": "^1.1.10",
21 | "@types/node": "^20",
22 | "@types/react": "^18",
23 | "@types/react-dom": "^18",
24 | "bun-types": "latest",
25 | "eslint": "^8",
26 | "eslint-config-next": "14.2.3",
27 | "postcss": "^8",
28 | "tailwindcss": "^3.4.1",
29 | "typescript": "^5"
30 | },
31 | "module": "index.ts",
32 | "type": "module"
33 | }
34 |
--------------------------------------------------------------------------------
/examples/nextjs/pages/api/pages-test.ts:
--------------------------------------------------------------------------------
1 | import { NextApiRequest, NextApiResponse } from "next";
2 |
3 | import { waitUntil } from '@vercel/functions';
4 | import { Ratelimit } from "@upstash/ratelimit";
5 | import { Redis } from "@upstash/redis";
6 |
7 | const redis = new Redis({
8 | url: process.env.UPSTASH_REDIS_REST_URL!,
9 | token: process.env.UPSTASH_REDIS_REST_TOKEN!,
10 | })
11 |
12 | const ratelimit = new Ratelimit({
13 | redis,
14 | limiter: Ratelimit.slidingWindow(10, "10 s"),
15 | prefix: "@upstash/ratelimit",
16 | analytics: true
17 | });
18 |
19 | export default async function handler(_req: NextApiRequest, res: NextApiResponse) {
20 | const identifier = "pages-api";
21 | const { success, limit, remaining, pending } = await ratelimit.limit(identifier);
22 | const response = {
23 | success: success,
24 | limit: limit,
25 | remaining: remaining
26 | }
27 |
28 | // pending is a promise for handling the analytics submission
29 | waitUntil(pending)
30 |
31 | res.status(success ? 200 : 429).json(response);
32 |
33 | }
34 |
--------------------------------------------------------------------------------
/examples/nextjs/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/examples/nextjs/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/nextjs/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/nextjs/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config: Config = {
4 | content: [
5 | "./pages/**/*.{js,ts,jsx,tsx,mdx}",
6 | "./components/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./app/**/*.{js,ts,jsx,tsx,mdx}",
8 | ],
9 | theme: {
10 | extend: {
11 | backgroundImage: {
12 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
13 | "gradient-conic":
14 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
15 | },
16 | },
17 | },
18 | plugins: [],
19 | };
20 | export default config;
21 |
--------------------------------------------------------------------------------
/examples/nextjs/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./*"]
22 | }
23 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------
/examples/remix/.env.example:
--------------------------------------------------------------------------------
1 | UPSTASH_REDIS_REST_URL=
2 | UPSTASH_REDIS_REST_TOKEN=
--------------------------------------------------------------------------------
/examples/remix/.eslintrc.js:
--------------------------------------------------------------------------------
1 | /** @type {import('eslint').Linter.Config} */
2 | module.exports = {
3 | extends: ["@remix-run/eslint-config", "@remix-run/eslint-config/node"],
4 | };
5 |
--------------------------------------------------------------------------------
/examples/remix/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 |
3 | .cache
4 | .env
5 | .vercel
6 | .output
7 |
8 | /build/
9 | /public/build
10 | /api/index.js
11 | /api/index.js.map
12 |
--------------------------------------------------------------------------------
/examples/remix/README.md:
--------------------------------------------------------------------------------
1 | # @upstash/ratelimit in Remix
2 |
3 | This example shows how to use `@upstash/ratelimit` in a Remix app.
4 |
5 | ## Getting Started
6 |
7 | Create a database on [Upstash](https://console.upstash.com/redis?new=true) and copy the `Upstash_REDIS_REST_URL` and `Upstash_REDIS_REST_TOKEN` from the database settings to your `.env` file.
8 |
9 | Then add a `loader` to your route like this:
10 |
11 | ```tsx
12 | import { json } from "@remix-run/node";
13 | import type { LoaderArgs } from "@remix-run/node";
14 | import { useLoaderData } from "@remix-run/react";
15 | import { Ratelimit } from "@upstash/ratelimit";
16 | import { Redis } from "@upstash/redis";
17 |
18 | const ratelimit = new Ratelimit({
19 | redis: Redis.fromEnv(),
20 | limiter: Ratelimit.fixedWindow(10, "60 s"),
21 | analytics: true,
22 | });
23 |
24 | export const loader = async (args: LoaderArgs) => {
25 | // getting the ip can be different depending on your hosting provider
26 | const ip = args.request.headers.get("X-Forwarded-For") ?? args.request.headers.get("x-real-ip");
27 | const identifier = ip ?? "global";
28 | const { success, limit, remaining, reset } = await ratelimit.limit(identifier);
29 | return json(
30 | {
31 | success,
32 | limit,
33 | remaining,
34 | reset,
35 | identifier,
36 | },
37 | {
38 | headers: {
39 | "X-RateLimit-Limit": limit.toString(),
40 | "X-RateLimit-Remaining": remaining.toString(),
41 | "X-RateLimit-Reset": reset.toString(),
42 | },
43 | },
44 | );
45 | };
46 |
47 | export default function Index() {
48 | const ratelimitResponse = useLoaderData();
49 |
50 | return (
51 |
52 |
Welcome to @upstash/ratelimit in Remix app
53 |
54 | {JSON.stringify(ratelimitResponse, null, 2)}
55 |
56 |
57 | );
58 | }
59 | ```
--------------------------------------------------------------------------------
/examples/remix/app/root.tsx:
--------------------------------------------------------------------------------
1 | import type { MetaFunction } from "@remix-run/node";
2 | import { Links, LiveReload, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react";
3 |
4 | export const meta: MetaFunction = () => ({
5 | charset: "utf-8",
6 | title: "New Remix App",
7 | viewport: "width=device-width,initial-scale=1",
8 | });
9 |
10 | export default function App() {
11 | return (
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/examples/remix/app/routes/index.tsx:
--------------------------------------------------------------------------------
1 | import { json } from "@remix-run/node";
2 | import type { LoaderArgs } from "@remix-run/node";
3 | import { useLoaderData } from "@remix-run/react";
4 | import { Ratelimit } from "@upstash/ratelimit";
5 | import { Redis } from "@upstash/redis";
6 |
7 | const ratelimit = new Ratelimit({
8 | redis: Redis.fromEnv(),
9 | limiter: Ratelimit.fixedWindow(10, "60 s"),
10 | analytics: true,
11 | });
12 |
13 | export const loader = async (args: LoaderArgs) => {
14 | // getting the ip can be different depending on your hosting provider
15 | const ip = args.request.headers.get("X-Forwarded-For") ?? args.request.headers.get("x-real-ip");
16 | const identifier = ip ?? "global";
17 | const { success, limit, remaining, reset } = await ratelimit.limit(identifier);
18 | return json(
19 | {
20 | success,
21 | limit,
22 | remaining,
23 | reset,
24 | identifier,
25 | },
26 | {
27 | headers: {
28 | "X-RateLimit-Limit": limit.toString(),
29 | "X-RateLimit-Remaining": remaining.toString(),
30 | "X-RateLimit-Reset": reset.toString(),
31 | },
32 | },
33 | );
34 | };
35 |
36 | export default function Index() {
37 | const ratelimitResponse = useLoaderData();
38 |
39 | return (
40 |
41 |
Welcome to @upstash/ratelimit in Remix app
42 |
43 | {JSON.stringify(ratelimitResponse, null, 2)}
44 |
45 |
46 | );
47 | }
48 |
--------------------------------------------------------------------------------
/examples/remix/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "private": true,
3 | "sideEffects": false,
4 | "scripts": {
5 | "build": "remix build",
6 | "dev": "remix dev",
7 | "typecheck": "tsc"
8 | },
9 | "dependencies": {
10 | "@remix-run/node": "^1.16.0",
11 | "@remix-run/react": "^1.16.0",
12 | "@remix-run/vercel": "^1.16.0",
13 | "@upstash/ratelimit": "^0.4.3",
14 | "@upstash/redis": "^1.20.6",
15 | "@vercel/node": "^2.14.2",
16 | "isbot": "^3.6.10",
17 | "react": "^18.2.0",
18 | "react-dom": "^18.2.0"
19 | },
20 | "devDependencies": {
21 | "@remix-run/dev": "^1.16.0",
22 | "@remix-run/eslint-config": "^1.16.0",
23 | "@remix-run/serve": "^1.16.0",
24 | "@types/react": "^18.2.6",
25 | "@types/react-dom": "^18.2.4",
26 | "eslint": "^8.40.0",
27 | "typescript": "^5.0.4"
28 | },
29 | "engines": {
30 | "node": ">=14"
31 | }
32 | }
33 |
--------------------------------------------------------------------------------
/examples/remix/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/upstash/ratelimit-js/a879eff5a9340ea3a523aa624ba318d40c0d3615/examples/remix/public/favicon.ico
--------------------------------------------------------------------------------
/examples/remix/remix.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('@remix-run/dev').AppConfig} */
2 | module.exports = {
3 | ignoredRouteFiles: ["**/.*"],
4 | // When running locally in development mode, we use the built-in remix
5 | // server. This does not understand the vercel lambda module format,
6 | // so we default back to the standard build output.
7 | server: process.env.NODE_ENV === "development" ? undefined : "./server.js",
8 | serverBuildPath: "api/index.js",
9 | // appDirectory: "app",
10 | // assetsBuildDirectory: "public/build",
11 | // publicPath: "/build/",
12 | };
13 |
--------------------------------------------------------------------------------
/examples/remix/remix.env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 | ///
3 |
--------------------------------------------------------------------------------
/examples/remix/server.js:
--------------------------------------------------------------------------------
1 | import * as build from "@remix-run/dev/server-build";
2 | import { createRequestHandler } from "@remix-run/vercel";
3 |
4 | export default createRequestHandler({ build, mode: process.env.NODE_ENV });
5 |
--------------------------------------------------------------------------------
/examples/remix/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
3 | "compilerOptions": {
4 | "lib": ["DOM", "DOM.Iterable", "ES2019"],
5 | "isolatedModules": true,
6 | "esModuleInterop": true,
7 | "jsx": "react-jsx",
8 | "moduleResolution": "node",
9 | "resolveJsonModule": true,
10 | "target": "ES2019",
11 | "strict": true,
12 | "allowJs": true,
13 | "forceConsistentCasingInFileNames": true,
14 | "baseUrl": ".",
15 | "paths": {
16 | "~/*": ["./app/*"]
17 | },
18 |
19 | // Remix takes care of building everything in `remix build`.
20 | "noEmit": true
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/examples/vercel-edge/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "next/core-web-vitals"
3 | }
4 |
--------------------------------------------------------------------------------
/examples/vercel-edge/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | .yarn/install-state.gz
8 |
9 | # testing
10 | /coverage
11 |
12 | # next.js
13 | /.next/
14 | /out/
15 |
16 | # production
17 | /build
18 |
19 | # misc
20 | .DS_Store
21 | *.pem
22 |
23 | # debug
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # local env files
29 | .env*.local
30 |
31 | # vercel
32 | .vercel
33 |
34 | # typescript
35 | *.tsbuildinfo
36 | next-env.d.ts
37 |
--------------------------------------------------------------------------------
/examples/vercel-edge/README.md:
--------------------------------------------------------------------------------
1 | # Rate Limiting a Vercel Edge API
2 |
3 | In this example, we define an API using Vercel Edge and utilize
4 | rate limiting to protect it.
5 |
6 | The api is defined in [the route.ts file](https://github.com/upstash/ratelimit/blob/main/examples/vercel-edge/app/api/route.ts) as follows:
7 |
8 | ```ts
9 | export const runtime = 'edge';
10 |
11 | export const dynamic = 'force-dynamic';
12 |
13 | import { waitUntil } from '@vercel/functions';
14 | import { Ratelimit } from "@upstash/ratelimit";
15 | import { Redis } from "@upstash/redis";
16 |
17 | // Create a new ratelimiter
18 | const ratelimit = new Ratelimit({
19 | redis: Redis.fromEnv(),
20 | limiter: Ratelimit.slidingWindow(10, "10 s"),
21 | prefix: "@upstash/ratelimit",
22 | analytics: true
23 | });
24 |
25 | export async function GET(request: Request) {
26 |
27 | const identifier = "api";
28 | const { success, limit, remaining, pending } = await ratelimit.limit(identifier);
29 | const response = {
30 | success: success,
31 | limit: limit,
32 | remaining: remaining
33 | }
34 |
35 | // pending is a promise for handling the analytics submission
36 | waitUntil(pending)
37 |
38 | if (!success) {
39 | return new Response(JSON.stringify(response), { status: 429 });
40 | }
41 | return new Response(JSON.stringify(response));
42 | }
43 | ```
44 |
45 | It runs on Vercel Edge and upon request, it returns the result of the rate limit call. This response is then shown on the home page.
46 |
47 | The `redis` parameter denotes the Upstash Redis instance we use. The `limiter` parameter denotes the algorithm used to limit requests. The `prefix` parameter is used when creating a key for entries in the Redis, allowing us to use a single Redis instance for different rate limiters. The `analytics` parameter denotes whether analytics will we sent to the Redis in order to use the Upstash Analytics dashboard.
48 |
49 | To limit the requests, we call `ratelimit.limit` method with an identifier `"api"`. This identifier could be the ip address or the user id in your use case. See [our documentation](https://upstash.com/docs/oss/sdks/ts/ratelimit/methods#limit) for more information.
50 |
51 | # Run Locally
52 |
53 | To run the example in your local environment, create a Upstash Redis and set the `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` environment variables. Then run
54 |
55 | ```
56 | npm run dev
57 | ```
58 |
59 | # Deploy to Vercel
60 |
61 | To deploy the project, install [Vercel CLI](https://vercel.com/docs/cli), set the environment variables `UPSTASH_REDIS_REST_URL` and `UPSTASH_REDIS_REST_TOKEN` on Vercel and run:
62 |
63 | ```bash
64 | vercel deploy
65 | ```
66 |
--------------------------------------------------------------------------------
/examples/vercel-edge/app/api/route.ts:
--------------------------------------------------------------------------------
1 | export const runtime = 'edge';
2 |
3 | export const dynamic = 'force-dynamic';
4 |
5 | import { waitUntil } from '@vercel/functions';
6 | import { Ratelimit } from "@upstash/ratelimit";
7 | import { Redis } from "@upstash/redis";
8 |
9 | // Create a new ratelimiter
10 | const ratelimit = new Ratelimit({
11 | redis: Redis.fromEnv(),
12 | limiter: Ratelimit.slidingWindow(10, "10 s"),
13 | prefix: "@upstash/ratelimit",
14 | analytics: true
15 | });
16 |
17 | export async function GET(request: Request) {
18 |
19 | const identifier = "api";
20 | const { success, limit, remaining, pending } = await ratelimit.limit(identifier);
21 | const response = {
22 | success: success,
23 | limit: limit,
24 | remaining: remaining
25 | }
26 |
27 | // pending is a promise for handling the analytics submission
28 | waitUntil(pending)
29 |
30 | if (!success) {
31 | return new Response(JSON.stringify(response), { status: 429 });
32 | }
33 | return new Response(JSON.stringify(response));
34 | }
--------------------------------------------------------------------------------
/examples/vercel-edge/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/upstash/ratelimit-js/a879eff5a9340ea3a523aa624ba318d40c0d3615/examples/vercel-edge/app/favicon.ico
--------------------------------------------------------------------------------
/examples/vercel-edge/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | --foreground-rgb: 0, 0, 0;
7 | --background-start-rgb: 249,250,250;
8 | --background-end-rgb: 236,238,238;
9 | }
10 |
11 | @media (prefers-color-scheme: dark) {
12 | :root {
13 | --foreground-rgb: 255, 255, 255;
14 | --background-start-rgb: 0, 0, 0;
15 | --background-end-rgb: 0, 0, 0;
16 | }
17 | }
18 |
19 | body {
20 | color: rgb(var(--foreground-rgb));
21 | background: linear-gradient(
22 | to bottom,
23 | transparent,
24 | rgb(var(--background-end-rgb))
25 | )
26 | rgb(var(--background-start-rgb));
27 | }
28 |
29 | @layer utilities {
30 | .text-balance {
31 | text-wrap: balance;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/examples/vercel-edge/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import type { Metadata } from "next";
2 | import { Inter } from "next/font/google";
3 | import "./globals.css";
4 |
5 | const inter = Inter({ subsets: ["latin"] });
6 |
7 | export const metadata: Metadata = {
8 | title: "Create Next App",
9 | description: "Generated by create next app",
10 | };
11 |
12 | export default function RootLayout({
13 | children,
14 | }: Readonly<{
15 | children: React.ReactNode;
16 | }>) {
17 | return (
18 |
19 | {children}
20 |
21 | );
22 | }
23 |
--------------------------------------------------------------------------------
/examples/vercel-edge/app/page.tsx:
--------------------------------------------------------------------------------
1 | "use client";
2 | import React, { useState } from "react";
3 |
4 | export default function Home() {
5 | const [status, setStatus] = useState({
6 | success: true,
7 | limit: 10,
8 | remaining: 10
9 | });
10 | const [message, setMessage] = useState("")
11 |
12 | const handleClick = async () => {
13 | try {
14 | const response = await fetch(`/api`);
15 | const data = await response.text();
16 | setStatus(JSON.parse(data));
17 | } catch (error) {
18 | console.error("Error fetching data:", error);
19 | setStatus({
20 | success: false,
21 | limit: -1,
22 | remaining: -1
23 | });
24 | setMessage(`Error fetching data. Make sure that env variables are set as explained in the README file.`,)
25 | }
26 | };
27 |
28 | return (
29 |
30 |
31 |
This app is an example of rate limiting an API on Vercel Edge.
32 |
Click the button below to call the API and get the rate limit status.
33 |
{message}
34 |
35 |
36 | {Object.entries(status).map(([key, value]) => (
37 |
38 |
{key}
39 |
{JSON.stringify(value)}
40 |
41 | ))}
42 |
43 |
44 |
47 |
48 |
49 | );
50 | }
51 |
--------------------------------------------------------------------------------
/examples/vercel-edge/next.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {};
3 |
4 | export default nextConfig;
5 |
--------------------------------------------------------------------------------
/examples/vercel-edge/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "vercel-edge",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@upstash/ratelimit": "^1.1.3",
13 | "@vercel/functions": "^1.0.2",
14 | "next": "14.2.23",
15 | "react": "^18",
16 | "react-dom": "^18"
17 | },
18 | "devDependencies": {
19 | "@types/node": "^20",
20 | "@types/react": "^18",
21 | "@types/react-dom": "^18",
22 | "eslint": "^8",
23 | "eslint-config-next": "14.2.3",
24 | "postcss": "^8",
25 | "tailwindcss": "^3.4.1",
26 | "typescript": "^5"
27 | }
28 | }
29 |
--------------------------------------------------------------------------------
/examples/vercel-edge/postcss.config.mjs:
--------------------------------------------------------------------------------
1 | /** @type {import('postcss-load-config').Config} */
2 | const config = {
3 | plugins: {
4 | tailwindcss: {},
5 | },
6 | };
7 |
8 | export default config;
9 |
--------------------------------------------------------------------------------
/examples/vercel-edge/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/vercel-edge/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/vercel-edge/tailwind.config.ts:
--------------------------------------------------------------------------------
1 | import type { Config } from "tailwindcss";
2 |
3 | const config: Config = {
4 | content: [
5 | "./pages/**/*.{js,ts,jsx,tsx,mdx}",
6 | "./components/**/*.{js,ts,jsx,tsx,mdx}",
7 | "./app/**/*.{js,ts,jsx,tsx,mdx}",
8 | ],
9 | theme: {
10 | extend: {
11 | backgroundImage: {
12 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
13 | "gradient-conic":
14 | "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
15 | },
16 | },
17 | },
18 | plugins: [],
19 | };
20 | export default config;
21 |
--------------------------------------------------------------------------------
/examples/vercel-edge/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "lib": ["dom", "dom.iterable", "esnext"],
4 | "allowJs": true,
5 | "skipLibCheck": true,
6 | "strict": true,
7 | "noEmit": true,
8 | "esModuleInterop": true,
9 | "module": "esnext",
10 | "moduleResolution": "bundler",
11 | "resolveJsonModule": true,
12 | "isolatedModules": true,
13 | "jsx": "preserve",
14 | "incremental": true,
15 | "plugins": [
16 | {
17 | "name": "next"
18 | }
19 | ],
20 | "paths": {
21 | "@/*": ["./*"]
22 | }
23 | },
24 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
25 | "exclude": ["node_modules"]
26 | }
27 |
--------------------------------------------------------------------------------
/examples/with-vercel-kv/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # next.js
12 | /.next/
13 | /out/
14 |
15 | # production
16 | /build
17 |
18 | # misc
19 | .DS_Store
20 | *.pem
21 |
22 | # debug
23 | npm-debug.log*
24 | yarn-debug.log*
25 | yarn-error.log*
26 |
27 | # local env files
28 | .env*.local
29 |
30 | # vercel
31 | .vercel
32 |
33 | # typescript
34 | *.tsbuildinfo
35 | next-env.d.ts
36 |
--------------------------------------------------------------------------------
/examples/with-vercel-kv/README.md:
--------------------------------------------------------------------------------
1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with
2 | [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app).
3 |
4 | ## Getting Started
5 |
6 | First, run the development server:
7 |
8 | ```bash
9 | npm run dev
10 | # or
11 | yarn dev
12 | # or
13 | pnpm dev
14 | ```
15 |
16 | Open [http://localhost:3000](http://localhost:3000) with your browser to see the
17 | result.
18 |
19 | You can start editing the page by modifying `app/page.tsx`. The page
20 | auto-updates as you edit the file.
21 |
22 | [http://localhost:3000/api/hello](http://localhost:3000/api/hello) is an
23 | endpoint that uses
24 | [Route Handlers](https://beta.nextjs.org/docs/routing/route-handlers). This
25 | endpoint can be edited in `app/api/hello/route.ts`.
26 |
27 | This project uses
28 | [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to
29 | automatically optimize and load Inter, a custom Google Font.
30 |
31 | ## Learn More
32 |
33 | To learn more about Next.js, take a look at the following resources:
34 |
35 | - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js
36 | features and API.
37 | - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
38 |
39 | You can check out
40 | [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your
41 | feedback and contributions are welcome!
42 |
43 | ## Deploy on Vercel
44 |
45 | The easiest way to deploy your Next.js app is to use the
46 | [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme)
47 | from the creators of Next.js.
48 |
49 | Check out our
50 | [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more
51 | details.
52 |
--------------------------------------------------------------------------------
/examples/with-vercel-kv/app/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/upstash/ratelimit-js/a879eff5a9340ea3a523aa624ba318d40c0d3615/examples/with-vercel-kv/app/favicon.ico
--------------------------------------------------------------------------------
/examples/with-vercel-kv/app/globals.css:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | :root {
6 | --foreground-rgb: 0, 0, 0;
7 | --background-start-rgb: 214, 219, 220;
8 | --background-end-rgb: 255, 255, 255;
9 | }
10 |
11 | @media (prefers-color-scheme: dark) {
12 | :root {
13 | --foreground-rgb: 255, 255, 255;
14 | --background-start-rgb: 0, 0, 0;
15 | --background-end-rgb: 0, 0, 0;
16 | }
17 | }
18 |
19 | body {
20 | color: rgb(var(--foreground-rgb));
21 | background: linear-gradient(
22 | to bottom,
23 | transparent,
24 | rgb(var(--background-end-rgb))
25 | )
26 | rgb(var(--background-start-rgb));
27 | }
28 |
--------------------------------------------------------------------------------
/examples/with-vercel-kv/app/layout.tsx:
--------------------------------------------------------------------------------
1 | import { Inter } from "next/font/google";
2 | import "./globals.css";
3 |
4 | const inter = Inter({ subsets: ["latin"] });
5 |
6 | export const metadata = {
7 | title: "Create Next App",
8 | description: "Generated by create next app",
9 | };
10 |
11 | export default function RootLayout({
12 | children,
13 | }: {
14 | children: React.ReactNode;
15 | }) {
16 | return (
17 |
18 | {children}
19 |
20 | );
21 | }
22 |
--------------------------------------------------------------------------------
/examples/with-vercel-kv/app/page.tsx:
--------------------------------------------------------------------------------
1 | import { Ratelimit } from "@upstash/ratelimit";
2 | import kv from "@vercel/kv";
3 | import { Inter } from "next/font/google";
4 | import { headers } from "next/headers";
5 | import Image from "next/image";
6 | import Link from "next/link";
7 |
8 | const ratelimit = new Ratelimit({
9 | redis: kv,
10 | limiter: Ratelimit.fixedWindow(10, "30s"),
11 | });
12 |
13 | export default async function Home() {
14 | const ip = headers().get("x-forwarded-for");
15 | const { success, limit, reset, remaining } = await ratelimit.limit(
16 | ip ?? "anonymous011"
17 | );
18 |
19 | return (
20 |
21 |
22 |
23 | Check out the source at
24 |
28 | github.com/upstash/ratelimit
29 |
30 |
31 |
32 |
33 |
34 | {success ? (
35 | <>
36 | @upstash/ratelimit
37 |
+
38 |
39 | Vercel KV
40 | >
41 | ) : (
42 | <>
43 | You have reached the limit,
44 |
45 | please come back later
46 | >
47 | )}
48 |
49 |
50 |
51 |
52 |
Success
53 |
54 | {success.toString()}
55 |
56 |
57 |
58 |
59 |
Limit
60 |
{limit}
61 |
62 |
63 |
64 |
Remaining
65 |
{remaining}
66 |
67 |
68 |
69 |
Reset
70 |
71 | {new Date(reset).toUTCString()}
72 |
73 |
74 |
75 |
76 | );
77 | }
78 |
--------------------------------------------------------------------------------
/examples/with-vercel-kv/next.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('next').NextConfig} */
2 | const nextConfig = {};
3 |
4 | module.exports = nextConfig;
5 |
--------------------------------------------------------------------------------
/examples/with-vercel-kv/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "with-vercel-kv",
3 | "version": "0.1.0",
4 | "private": true,
5 | "scripts": {
6 | "dev": "next dev",
7 | "build": "next build",
8 | "start": "next start",
9 | "lint": "next lint"
10 | },
11 | "dependencies": {
12 | "@types/node": "20.1.2",
13 | "@types/react": "18.2.6",
14 | "@types/react-dom": "18.2.4",
15 | "@upstash/ratelimit": "^0.4.3",
16 | "@vercel/kv": "^0.1.2",
17 | "autoprefixer": "10.4.14",
18 | "next": "14.2.23",
19 | "postcss": "8.5.1",
20 | "react": "18.2.0",
21 | "react-dom": "18.2.0",
22 | "tailwindcss": "3.3.2",
23 | "typescript": "5.0.4"
24 | },
25 | "packageManager": "bun@1.1.0"
26 | }
27 |
--------------------------------------------------------------------------------
/examples/with-vercel-kv/postcss.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/examples/with-vercel-kv/public/next.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/with-vercel-kv/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/examples/with-vercel-kv/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | module.exports = {
3 | content: [
4 | "./pages/**/*.{js,ts,jsx,tsx,mdx}",
5 | "./components/**/*.{js,ts,jsx,tsx,mdx}",
6 | "./app/**/*.{js,ts,jsx,tsx,mdx}",
7 | ],
8 | theme: {
9 | extend: {
10 | backgroundImage: {
11 | "gradient-radial": "radial-gradient(var(--tw-gradient-stops))",
12 | "gradient-conic": "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))",
13 | },
14 | },
15 | },
16 | plugins: [],
17 | };
18 |
--------------------------------------------------------------------------------
/examples/with-vercel-kv/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": ["dom", "dom.iterable", "esnext"],
5 | "allowJs": true,
6 | "skipLibCheck": true,
7 | "strict": true,
8 | "forceConsistentCasingInFileNames": true,
9 | "noEmit": true,
10 | "esModuleInterop": true,
11 | "module": "esnext",
12 | "moduleResolution": "node",
13 | "resolveJsonModule": true,
14 | "isolatedModules": true,
15 | "jsx": "preserve",
16 | "incremental": true,
17 | "plugins": [
18 | {
19 | "name": "next"
20 | }
21 | ],
22 | "paths": {
23 | "@/*": ["./*"]
24 | }
25 | },
26 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
27 | "exclude": ["node_modules"]
28 | }
29 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@upstash/ratelimit",
3 | "version": "1.1.2",
4 | "main": "./dist/index.js",
5 | "types": "./dist/index.d.ts",
6 | "files": [
7 | "dist"
8 | ],
9 | "scripts": {
10 | "build": "tsup",
11 | "test": "bun test src --coverage",
12 | "fmt": "prettier --write .",
13 | "lint": "eslint \"src/**/*.{js,ts,tsx}\" --quiet --fix"
14 | },
15 | "devDependencies": {
16 | "@typescript-eslint/eslint-plugin": "^8.4.0",
17 | "bun-types": "latest",
18 | "eslint": "^9.10.0",
19 | "eslint-plugin-unicorn": "^55.0.0",
20 | "tsup": "^7.2.0",
21 | "turbo": "^1.10.15",
22 | "typescript": "^5.0.0"
23 | },
24 | "peerDependencies": {
25 | "@upstash/redis": "^1.34.3"
26 | },
27 | "license": "MIT",
28 | "dependencies": {
29 | "@upstash/core-analytics": "^0.0.10"
30 | }
31 | }
--------------------------------------------------------------------------------
/prettier.config.js:
--------------------------------------------------------------------------------
1 | /**
2 | * @type {import('prettier').Config}
3 | */
4 | const config = {
5 | endOfLine: "lf",
6 | singleQuote: false,
7 | tabWidth: 2,
8 | trailingComma: "es5",
9 | printWidth: 100,
10 | arrowParens: "always",
11 | };
12 |
13 | export default config;
14 |
--------------------------------------------------------------------------------
/src/analytics.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from "bun:test";
2 | import crypto from "node:crypto";
3 | import { Redis } from "@upstash/redis";
4 | import { Analytics } from "./analytics";
5 |
6 | test("analytics", async () => {
7 | const redis = Redis.fromEnv();
8 | const a = new Analytics({ redis, prefix: crypto.randomUUID() });
9 | const time = Date.now();
10 | for (let i = 0; i < 20; i++) {
11 | await a.record({
12 | identifier: "id",
13 | success: true,
14 | time,
15 | });
16 | }
17 |
18 | const usage = await a.getUsage(Date.now() - 1000 * 60 * 60 * 24);
19 | expect(Object.entries(usage).length).toBe(1);
20 | expect(Object.keys(usage)).toContain("id");
21 | expect(usage.id.success).toBe(20);
22 | expect(usage.id.blocked).toBe(0);
23 | });
24 |
--------------------------------------------------------------------------------
/src/analytics.ts:
--------------------------------------------------------------------------------
1 | import type { Aggregate } from "@upstash/core-analytics";
2 | import { Analytics as CoreAnalytics } from "@upstash/core-analytics";
3 | import type { Redis } from "./types";
4 |
5 | export type Geo = {
6 | country?: string;
7 | city?: string;
8 | region?: string;
9 | ip?: string;
10 | };
11 |
12 | /**
13 | * denotes the success field in the analytics submission.
14 | * Set to true when ratelimit check passes. False when request is ratelimited.
15 | * Set to "denied" when some request value is in deny list.
16 | */
17 | export type EventSuccess = boolean | "denied"
18 |
19 | export type Event = Geo & {
20 | identifier: string;
21 | time: number;
22 | success: EventSuccess;
23 | };
24 |
25 | export type AnalyticsConfig = {
26 | redis: Redis;
27 | prefix?: string;
28 | };
29 |
30 | /**
31 | * The Analytics package is experimental and can change at any time.
32 | */
33 | export class Analytics {
34 | private readonly analytics: CoreAnalytics;
35 | private readonly table = "events";
36 |
37 | constructor(config: AnalyticsConfig) {
38 | this.analytics = new CoreAnalytics({
39 | // @ts-expect-error we need to fix the types in core-analytics, it should only require the methods it needs, not the whole sdk
40 | redis: config.redis,
41 | window: "1h",
42 | prefix: config.prefix ?? "@upstash/ratelimit",
43 | retention: "90d",
44 | });
45 | }
46 |
47 | /**
48 | * Try to extract the geo information from the request
49 | *
50 | * This handles Vercel's `req.geo` and and Cloudflare's `request.cf` properties
51 | * @param req
52 | * @returns
53 | */
54 | public extractGeo(req: { geo?: Geo; cf?: Geo }): Geo {
55 | if (req.geo !== undefined) {
56 | return req.geo;
57 | }
58 | if (req.cf !== undefined) {
59 | return req.cf;
60 | }
61 |
62 | return {};
63 | }
64 |
65 | public async record(event: Event): Promise {
66 | await this.analytics.ingest(this.table, event);
67 | }
68 |
69 | public async series>(
70 | filter: TFilter,
71 | cutoff: number,
72 | ): Promise {
73 | const timestampCount = Math.min(
74 | (
75 | this.analytics.getBucket(Date.now())
76 | - this.analytics.getBucket(cutoff)
77 | ) / (60 * 60 * 1000),
78 | 256
79 | )
80 | return this.analytics.aggregateBucketsWithPipeline(this.table, filter, timestampCount)
81 | }
82 |
83 | public async getUsage(cutoff = 0): Promise> {
84 |
85 | const timestampCount = Math.min(
86 | (
87 | this.analytics.getBucket(Date.now())
88 | - this.analytics.getBucket(cutoff)
89 | ) / (60 * 60 * 1000),
90 | 256
91 | )
92 | const records = await this.analytics.getAllowedBlocked(this.table, timestampCount)
93 | return records;
94 | }
95 |
96 | public async getUsageOverTime>(
97 | timestampCount: number, groupby: TFilter
98 | ): Promise {
99 | const result = await this.analytics.aggregateBucketsWithPipeline(this.table, groupby, timestampCount)
100 | return result
101 | }
102 |
103 | public async getMostAllowedBlocked(timestampCount: number, getTop?: number, checkAtMost?: number) {
104 | getTop = getTop ?? 5
105 | const timestamp = undefined // let the analytics handle getting the timestamp
106 | return this.analytics.getMostAllowedBlocked(this.table, timestampCount, getTop, timestamp, checkAtMost)
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/src/blockUntilReady.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, test } from "bun:test";
2 | import crypto from "node:crypto";
3 | import { Redis } from "@upstash/redis";
4 | import { Ratelimit } from "./index";
5 |
6 | const redis = Redis.fromEnv();
7 |
8 | const metrics: Record = {};
9 |
10 | const spy = new Proxy(redis, {
11 | get: (target, prop) => {
12 | if (metrics[prop] === undefined) {
13 | metrics[prop] = 0;
14 | }
15 | metrics[prop]++;
16 | // @ts-expect-error we don't care about the types here
17 | return target[prop];
18 | },
19 | });
20 | const limiter = new Ratelimit({
21 | redis: spy,
22 | limiter: Ratelimit.fixedWindow(5, "5 s"),
23 | });
24 |
25 | describe("blockUntilReady", () => {
26 | test("reaching the timeout", async () => {
27 | const id = crypto.randomUUID();
28 |
29 | // Use up all tokens in the current window
30 | for (let i = 0; i < 15; i++) {
31 | await limiter.limit(id);
32 | }
33 |
34 | const start = Date.now();
35 | const res = await limiter.blockUntilReady(id, 1200);
36 | expect(res.success).toBe(false);
37 | expect(start + 1000).toBeLessThanOrEqual(Date.now());
38 | await res.pending;
39 | }, 20_000);
40 |
41 | test("resolving before the timeout", async () => {
42 | const id = crypto.randomUUID();
43 |
44 | // Use up all tokens in the current window
45 | // for (let i = 0; i < 4; i++) {
46 | // await limiter.limit(id);
47 | // }
48 |
49 | const start = Date.now();
50 | const res = await limiter.blockUntilReady(id, 1000);
51 | expect(res.success).toBe(true);
52 | expect(start + 1000).toBeGreaterThanOrEqual(Date.now());
53 |
54 | await res.pending;
55 | }, 20_000);
56 | });
57 |
--------------------------------------------------------------------------------
/src/cache.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, test } from "bun:test";
2 | import { Redis } from "@upstash/redis";
3 | import { Ratelimit } from "./index";
4 |
5 | test("ephemeral cache", async () => {
6 | const maxTokens = 10;
7 | const redis = Redis.fromEnv();
8 | await redis.scriptFlush()
9 |
10 | const metrics: Record = {};
11 |
12 | const spy = new Proxy(redis, {
13 | get: (target, prop) => {
14 | if (metrics[prop] === undefined) {
15 | metrics[prop] = 0;
16 | }
17 | metrics[prop]++;
18 | // @ts-expect-error - we don't care about the types here
19 | return target[prop];
20 | },
21 | });
22 | const ratelimit = new Ratelimit({
23 | redis: spy,
24 | limiter: Ratelimit.tokenBucket(maxTokens, "5 s", maxTokens),
25 | ephemeralCache: new Map(),
26 | });
27 |
28 | let passes = 0;
29 |
30 | const reasons: (string | undefined)[] = []
31 | for (let i = 0; i <= 20; i++) {
32 | const { success, reason } = await ratelimit.limit("id");
33 | if (success) {
34 | passes++;
35 | } else {
36 | reasons.push(reason)
37 | }
38 | }
39 |
40 | expect(passes).toBeLessThanOrEqual(10);
41 | expect(metrics.evalsha).toBe(12);
42 | expect(reasons).toContain("cacheBlock")
43 |
44 | await new Promise((r) => setTimeout(r, 5000));
45 | }, 10_000);
46 |
--------------------------------------------------------------------------------
/src/cache.ts:
--------------------------------------------------------------------------------
1 | import type { EphemeralCache } from "./types";
2 |
3 | export class Cache implements EphemeralCache {
4 | /**
5 | * Stores identifier -> reset (in milliseconds)
6 | */
7 | private readonly cache: Map;
8 |
9 | constructor(cache: Map) {
10 | this.cache = cache;
11 | }
12 |
13 | public isBlocked(identifier: string): { blocked: boolean; reset: number } {
14 | if (!this.cache.has(identifier)) {
15 | return { blocked: false, reset: 0 };
16 | }
17 | const reset = this.cache.get(identifier)!;
18 | if (reset < Date.now()) {
19 | this.cache.delete(identifier);
20 | return { blocked: false, reset: 0 };
21 | }
22 |
23 | return { blocked: true, reset: reset };
24 | }
25 |
26 | public blockUntil(identifier: string, reset: number): void {
27 | this.cache.set(identifier, reset);
28 | }
29 |
30 | public set(key: string, value: number): void {
31 | this.cache.set(key, value);
32 | }
33 | public get(key: string): number | null {
34 | return this.cache.get(key) || null;
35 | }
36 |
37 | public incr(key: string): number {
38 | let value = this.cache.get(key) ?? 0;
39 | value += 1;
40 | this.cache.set(key, value);
41 | return value;
42 | }
43 |
44 | public pop(key: string): void {
45 | this.cache.delete(key)
46 | }
47 |
48 | public empty(): void {
49 | this.cache.clear()
50 | }
51 |
52 | public size(): number {
53 | return this.cache.size;
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/deny-list/deny-list.test.ts:
--------------------------------------------------------------------------------
1 | import { expect, test, describe, afterAll, beforeAll } from "bun:test";
2 | import { Redis } from "@upstash/redis";
3 | import { Ratelimit } from "../index";
4 | import { checkDenyListCache, defaultDeniedResponse, resolveLimitPayload } from "./deny-list";
5 | import type { DenyListResponse, RatelimitResponseType } from "../types";
6 |
7 |
8 | test("should get expected response from defaultDeniedResponse", () => {
9 | const deniedValue = "testValue";
10 | const response = defaultDeniedResponse(deniedValue);
11 |
12 | expect(response).toEqual({
13 | success: false,
14 | limit: 0,
15 | remaining: 0,
16 | reset: 0,
17 | pending: expect.any(Promise),
18 | reason: "denyList",
19 | deniedValue: deniedValue
20 | });
21 | });
22 |
23 | describe("should resolve ratelimit and deny list response", async () => {
24 | const redis = Redis.fromEnv();
25 | const prefix = `test-resolve-prefix`;
26 |
27 | const initialResponse = {
28 | success: true,
29 | limit: 100,
30 | remaining: 50,
31 | reset: 60,
32 | pending: Promise.resolve(),
33 | reason: undefined,
34 | deniedValue: undefined
35 | };
36 |
37 | const expectedResponse = {
38 | success: false,
39 | limit: 100,
40 | remaining: 0,
41 | reset: 60,
42 | pending: Promise.resolve(),
43 | reason: "denyList" as RatelimitResponseType,
44 | deniedValue: "testValue"
45 | };
46 |
47 | test("should update ip deny list when invalidIpDenyList is true", async () => {
48 | let callCount = 0;
49 | const spyRedis = {
50 | multi: () => {
51 | callCount += 1;
52 | return redis.multi();
53 | }
54 | }
55 |
56 | const denyListResponse: DenyListResponse = {
57 | deniedValue: "testValue",
58 | invalidIpDenyList: true
59 | };
60 |
61 | const response = resolveLimitPayload(spyRedis as Redis, prefix, [initialResponse, denyListResponse], 8);
62 | await response.pending;
63 |
64 | expect(response).toEqual(expectedResponse);
65 | expect(callCount).toBe(1) // calls multi once to store ips
66 | });
67 |
68 | test("should update ip deny list when invalidIpDenyList is true", async () => {
69 |
70 | let callCount = 0;
71 | const spyRedis = {
72 | multi: () => {
73 | callCount += 1;
74 | return redis.multi();
75 | }
76 | }
77 |
78 | const denyListResponse: DenyListResponse = {
79 | deniedValue: "testValue",
80 | invalidIpDenyList: false
81 | };
82 |
83 | const response = resolveLimitPayload(spyRedis as Redis, prefix, [initialResponse, denyListResponse], 8);
84 | await response.pending;
85 |
86 | expect(response).toEqual(expectedResponse);
87 | expect(callCount).toBe(0) // doesn't call multi to update deny list
88 | });
89 | })
90 |
91 |
92 | describe("should reject in deny list", async () => {
93 | const redis = Redis.fromEnv();
94 | const prefix = `test-prefix`;
95 | const denyListKey = [prefix, "denyList", "all"].join(":");
96 |
97 |
98 | const ratelimit = new Ratelimit({
99 | redis,
100 | limiter: Ratelimit.tokenBucket(10, "5 s", 10),
101 | prefix,
102 | enableProtection: true,
103 | denyListThreshold: 8
104 | });
105 |
106 | afterAll(async () => {
107 | await redis.del(denyListKey)
108 | })
109 |
110 | // Insert a value into the deny list
111 | beforeAll(async () => {
112 | await redis.sadd(denyListKey, "denyIdentifier", "denyIp", "denyAgent", "denyCountry");
113 | })
114 |
115 | test("should allow with values not in the deny list", async () => {
116 | const { success, reason } = await ratelimit.limit("randomValue");
117 |
118 | expect(success).toBe(true);
119 | expect(reason).toBeUndefined();
120 | });
121 |
122 | test("should deny with identifier in the deny list", async () => {
123 |
124 | const { success, reason } = await ratelimit.limit("denyIdentifier");
125 |
126 | expect(success).toBe(false);
127 | expect(reason).toBe("denyList");
128 |
129 | const cacheCheck = checkDenyListCache(["denyIdentifier"]);
130 | expect(cacheCheck).toBe("denyIdentifier");
131 | });
132 |
133 | test("should deny with ip in the deny list", async () => {
134 |
135 | const { success, reason } = await ratelimit.limit("some-value", { ip: "denyIp" });
136 |
137 | expect(success).toBe(false);
138 | expect(reason).toBe("denyList");
139 |
140 | const cacheCheck = checkDenyListCache(["denyIp"]);
141 | expect(cacheCheck).toBe("denyIp");
142 | });
143 |
144 | test("should deny with user agent in the deny list", async () => {
145 |
146 | const { success, reason } = await ratelimit.limit("some-value", { userAgent: "denyAgent" });
147 |
148 | expect(success).toBe(false);
149 | expect(reason).toBe("denyList");
150 |
151 | const cacheCheck = checkDenyListCache(["denyAgent"]);
152 | expect(cacheCheck).toBe("denyAgent");
153 | });
154 |
155 | test("should deny with country in the deny list", async () => {
156 |
157 | const { success, reason } = await ratelimit.limit("some-value", { country: "denyCountry" });
158 |
159 | expect(success).toBe(false);
160 | expect(reason).toBe("denyList");
161 |
162 | const cacheCheck = checkDenyListCache(["denyCountry"]);
163 | expect(cacheCheck).toBe("denyCountry");
164 | });
165 |
166 | test("should deny with multiple in deny list", async () => {
167 |
168 | const { success, reason } = await ratelimit.limit("denyIdentifier", { country: "denyCountry" });
169 |
170 | expect(success).toBe(false);
171 | expect(reason).toBe("denyList");
172 |
173 | const cacheCheck = checkDenyListCache(["denyIdentifier"]);
174 | expect(cacheCheck).toBe("denyIdentifier");
175 | });
176 | })
--------------------------------------------------------------------------------
/src/deny-list/deny-list.ts:
--------------------------------------------------------------------------------
1 | import type { DeniedValue, DenyListResponse, LimitPayload} from "../types";
2 | import { DenyListExtension, IpDenyListStatusKey } from "../types"
3 | import type { RatelimitResponse, Redis } from "../types"
4 | import { Cache } from "../cache";
5 | import { checkDenyListScript } from "./scripts";
6 | import { updateIpDenyList } from "./ip-deny-list";
7 |
8 |
9 | const denyListCache = new Cache(new Map());
10 |
11 | /**
12 | * Checks items in members list and returns the first denied member
13 | * in denyListCache if there are any.
14 | *
15 | * @param members list of values to check against the cache
16 | * @returns a member from the cache. If there is none, returns undefined
17 | */
18 | export const checkDenyListCache = (members: string[]): DeniedValue => {
19 | return members.find(
20 | member => denyListCache.isBlocked(member).blocked
21 | );
22 | }
23 |
24 | /**
25 | * Blocks a member for 1 minute.
26 | *
27 | * If there are more than 1000 elements in the cache, empties
28 | * it so that the cache doesn't grow in size indefinetely.
29 | *
30 | * @param member member to block
31 | */
32 | const blockMember = (member: string) => {
33 | if (denyListCache.size() > 1000) denyListCache.empty();
34 | denyListCache.blockUntil(member, Date.now() + 60_000);
35 | }
36 |
37 | /**
38 | * Checks if identifier or any of the values are in any of
39 | * the denied lists in Redis.
40 | *
41 | * If some value is in a deny list, we block the identifier for a minute.
42 | *
43 | * @param redis redis client
44 | * @param prefix ratelimit prefix
45 | * @param members List of values (identifier, ip, user agent, country)
46 | * @returns true if a member is in deny list at Redis
47 | */
48 | export const checkDenyList = async (
49 | redis: Redis,
50 | prefix: string,
51 | members: string[]
52 | ): Promise => {
53 | const [ deniedValues, ipDenyListStatus ] = await redis.eval(
54 | checkDenyListScript,
55 | [
56 | [prefix, DenyListExtension, "all"].join(":"),
57 | [prefix, IpDenyListStatusKey].join(":"),
58 | ],
59 | members
60 | ) as [boolean[], number];
61 |
62 | let deniedValue: DeniedValue = undefined;
63 | deniedValues.map((memberDenied, index) => {
64 | if (memberDenied) {
65 | blockMember(members[index])
66 | deniedValue = members[index]
67 | }
68 | })
69 |
70 | return {
71 | deniedValue,
72 | invalidIpDenyList: ipDenyListStatus === -2
73 | };
74 | };
75 |
76 | /**
77 | * Overrides the rate limit response if deny list
78 | * response indicates that value is in deny list.
79 | *
80 | * @param ratelimitResponse
81 | * @param denyListResponse
82 | * @returns
83 | */
84 | export const resolveLimitPayload = (
85 | redis: Redis,
86 | prefix: string,
87 | [ratelimitResponse, denyListResponse]: LimitPayload,
88 | threshold: number
89 | ): RatelimitResponse => {
90 |
91 | if (denyListResponse.deniedValue) {
92 | ratelimitResponse.success = false;
93 | ratelimitResponse.remaining = 0;
94 | ratelimitResponse.reason = "denyList";
95 | ratelimitResponse.deniedValue = denyListResponse.deniedValue
96 | }
97 |
98 | if (denyListResponse.invalidIpDenyList) {
99 | const updatePromise = updateIpDenyList(redis, prefix, threshold)
100 | ratelimitResponse.pending = Promise.all([
101 | ratelimitResponse.pending,
102 | updatePromise
103 | ])
104 | }
105 |
106 | return ratelimitResponse;
107 | };
108 |
109 | /**
110 | *
111 | * @returns Default response to return when some item
112 | * is in deny list.
113 | */
114 | export const defaultDeniedResponse = (deniedValue: string): RatelimitResponse => {
115 | return {
116 | success: false,
117 | limit: 0,
118 | remaining: 0,
119 | reset: 0,
120 | pending: Promise.resolve(),
121 | reason: "denyList",
122 | deniedValue: deniedValue
123 | }
124 | }
125 |
--------------------------------------------------------------------------------
/src/deny-list/index.ts:
--------------------------------------------------------------------------------
1 | export * from "./deny-list"
--------------------------------------------------------------------------------
/src/deny-list/integration.test.ts:
--------------------------------------------------------------------------------
1 | // test ip deny list from the highest level, using Ratelimit
2 | import { expect, test, describe, beforeEach } from "bun:test";
3 | import { Ratelimit } from "../index";
4 | import { Redis } from "@upstash/redis";
5 | import type { RatelimitResponse } from "../types";
6 | import { DenyListExtension, IpDenyListKey, IpDenyListStatusKey } from "../types";
7 | import { disableIpDenyList } from "./ip-deny-list";
8 |
9 | describe("should reject in deny list", async () => {
10 |
11 | const redis = Redis.fromEnv();
12 | const prefix = `test-integration-prefix`;
13 | const statusKey = [prefix, IpDenyListStatusKey].join(":")
14 | const allDenyListsKey = [prefix, DenyListExtension, "all"].join(":");
15 | const ipDenyListsKey = [prefix, DenyListExtension, IpDenyListKey].join(":");
16 |
17 | const ratelimit = new Ratelimit({
18 | redis,
19 | limiter: Ratelimit.tokenBucket(10, "5 s", 10),
20 | prefix,
21 | enableProtection: true,
22 | denyListThreshold: 8
23 | });
24 |
25 | beforeEach(async () => {
26 | await redis.flushdb()
27 | // adding different values to avoid the deny list cache
28 | await redis.sadd(allDenyListsKey, "foo", "albatros", "penguin");
29 | });
30 |
31 | test("should not check deny list when enableProtection: false", async () => {
32 | const ratelimit = new Ratelimit({
33 | redis,
34 | limiter: Ratelimit.tokenBucket(10, "5 s", 10),
35 | prefix,
36 | enableProtection: false,
37 | denyListThreshold: 8
38 | });
39 |
40 | const result = await ratelimit.limit("albatros")
41 | expect(result.success).toBeTrue()
42 |
43 | const [status, statusTTL, allSize, ipListsize] = await Promise.all([
44 | redis.get(statusKey),
45 | redis.ttl(statusKey),
46 | redis.scard(allDenyListsKey),
47 | redis.scard(ipDenyListsKey),
48 | ])
49 |
50 | // no status flag
51 | expect(status).toBe(null)
52 | expect(statusTTL).toBe(-2)
53 | expect(allSize).toBe(3) // foo + albatros + penguin
54 | expect(ipListsize).toBe(0)
55 | })
56 |
57 | test("should create ip denylist when enableProtection: true and not disabled", async () => {
58 | const { pending, success } = await ratelimit.limit("penguin");
59 | expect(success).toBeFalse()
60 | await pending;
61 |
62 | const [status, statusTTL, allSize, ipListsize] = await Promise.all([
63 | redis.get(statusKey),
64 | redis.ttl(statusKey),
65 | redis.scard(allDenyListsKey),
66 | redis.scard(ipDenyListsKey),
67 | ])
68 |
69 | // status flag exists and has ttl
70 | expect(status).toBe("valid")
71 | expect(statusTTL).toBeGreaterThan(1000)
72 | expect(allSize).toBeGreaterThan(0)
73 | expect(ipListsize).toBe(allSize - 3) // foo + albatros + penguin
74 | })
75 |
76 | test("should not create ip denylist when enableProtection: true but flag is disabled", async () => {
77 | await disableIpDenyList(redis, prefix);
78 | const { pending, success } = await ratelimit.limit("test-user-2");
79 | expect(success).toBeTrue()
80 | await pending;
81 |
82 | const [status, statusTTL, allSize, ipListsize] = await Promise.all([
83 | redis.get(statusKey),
84 | redis.ttl(statusKey),
85 | redis.scard(allDenyListsKey),
86 | redis.scard(ipDenyListsKey),
87 | ])
88 |
89 | // no status flag
90 | expect(status).toBe("disabled")
91 | expect(statusTTL).toBe(-1)
92 | expect(allSize).toBe(3) // foo + albatros + penguin
93 | expect(ipListsize).toBe(0)
94 | })
95 |
96 | test("should observe that ip denylist is deleted after disabling", async () => {
97 | const { pending, success } = await ratelimit.limit("test-user-3");
98 | expect(success).toBeTrue()
99 | await pending;
100 |
101 | const [status, statusTTL, allSize, ipListsize] = await Promise.all([
102 | redis.get(statusKey),
103 | redis.ttl(statusKey),
104 | redis.scard(allDenyListsKey),
105 | redis.scard(ipDenyListsKey),
106 | ])
107 |
108 | // status flag exists and has ttl
109 | expect(status).toBe("valid")
110 | expect(statusTTL).toBeGreaterThan(1000)
111 | expect(allSize).toBeGreaterThan(0)
112 | expect(ipListsize).toBe(allSize - 3) // foo + albatros + penguin
113 |
114 | // DISABLE: called from UI
115 | await disableIpDenyList(redis, prefix);
116 |
117 | // call again
118 | const { pending: newPending } = await ratelimit.limit("test-user");
119 | await newPending;
120 |
121 | const [newStatus, newStatusTTL, newAllSize, newIpListsize] = await Promise.all([
122 | redis.get(statusKey),
123 | redis.ttl(statusKey),
124 | redis.scard(allDenyListsKey),
125 | redis.scard(ipDenyListsKey),
126 | ])
127 |
128 | // status flag exists and has ttl
129 | expect(newStatus).toBe("disabled")
130 | expect(newStatusTTL).toBe(-1)
131 | expect(newAllSize).toBe(3) // foo + albatros + penguin
132 | expect(newIpListsize).toBe(0)
133 | })
134 |
135 | test("should intialize ip list only once when called consecutively", async () => {
136 |
137 | const requests: RatelimitResponse[] = await Promise.all([
138 | ratelimit.limit("test-user-X"),
139 | ratelimit.limit("test-user-Y")
140 | ])
141 |
142 | expect(requests[0].success).toBeTrue()
143 | expect(requests[1].success).toBeTrue()
144 |
145 | // wait for both to finish
146 | const result = await Promise.all([
147 | requests[0].pending,
148 | requests[1].pending
149 | ])
150 | /**
151 | * Result is like this:
152 | * [
153 | * undefined,
154 | * [
155 | * undefined,
156 | * [ 1, 0, 74, 74, 75, "OK" ]
157 | * ]
158 | * ]
159 | *
160 | * the first is essentially:
161 | * >> Promise.resolve()
162 | *
163 | * Second one is
164 | * >> Promise.all([Promise.resolve(), updateIpDenyListPromise])
165 | *
166 | * This means that even though the requests were consecutive, only one was
167 | * allowed to update to update the ip list!
168 | */
169 |
170 | // only one undefined
171 | expect(result.filter((value) => value === undefined).length).toBe(1)
172 |
173 | // other response is defined
174 | const definedResponse = result.find((value) => value !== undefined) as [undefined, any[]]
175 | expect(definedResponse[0]).toBe(undefined)
176 | expect(definedResponse[1].length).toBe(6)
177 | expect(definedResponse[1][1]).toBe(0) // deleting deny list fails because there is none
178 | expect(definedResponse[1][5]).toBe("OK") // setting TTL returns OK
179 | })
180 |
181 | test("should block ips from ip deny list", async () => {
182 | const { pending, success } = await ratelimit.limit("test-user");
183 | expect(success).toBeTrue()
184 | await pending;
185 |
186 | const [ip1, ip2] = await redis.srandmember(ipDenyListsKey, 2) as string[]
187 |
188 | const result = await ratelimit.limit("test-user", { ip: ip1 })
189 | expect(result.success).toBeFalse()
190 | expect(result.reason).toBe("denyList")
191 |
192 | await disableIpDenyList(redis, prefix);
193 |
194 | // first one still returns false because it is cached
195 | const newResult = await ratelimit.limit("test-user", { ip: ip1 })
196 | expect(newResult.success).toBeFalse()
197 | expect(newResult.reason).toBe("denyList")
198 |
199 | // other one returns true
200 | const otherResult = await ratelimit.limit("test-user", { ip: ip2 })
201 | expect(otherResult.success).toBeTrue()
202 | })
203 | })
--------------------------------------------------------------------------------
/src/deny-list/ip-deny-list.test.ts:
--------------------------------------------------------------------------------
1 | import { Redis } from "@upstash/redis";
2 | import { beforeEach, describe, expect, test } from "bun:test";
3 | import { checkDenyList } from "./deny-list";
4 | import { disableIpDenyList, updateIpDenyList } from "./ip-deny-list";
5 | import { DenyListExtension, IpDenyListKey, IpDenyListStatusKey } from "../types";
6 | import { Ratelimit } from "..";
7 |
8 | describe("should update ip deny list status", async () => {
9 | const redis = Redis.fromEnv();
10 | const prefix = `test-ip-list-prefix`;
11 | const allDenyListsKey = [prefix, DenyListExtension, "all"].join(":");
12 | const ipDenyListsKey = [prefix, DenyListExtension, IpDenyListKey].join(":");
13 | const statusKey = [prefix, IpDenyListStatusKey].join(":")
14 |
15 | beforeEach(async () => {
16 | await redis.flushdb()
17 | await redis.sadd(
18 | allDenyListsKey, "foo", "bar")
19 | });
20 |
21 | test("should return invalidIpDenyList: true when empty", async () => {
22 | const { deniedValue, invalidIpDenyList } = await checkDenyList(
23 | redis, prefix, ["foo", "bar"]
24 | )
25 |
26 | expect(deniedValue).toBe("bar")
27 | expect(invalidIpDenyList).toBeTrue()
28 | })
29 |
30 | test("should return invalidIpDenyList: false when disabled", async () => {
31 | await disableIpDenyList(redis, prefix);
32 | const { deniedValue, invalidIpDenyList } = await checkDenyList(
33 | redis, prefix, ["bar", "foo"]
34 | )
35 |
36 | expect(deniedValue).toBe("foo")
37 | expect(invalidIpDenyList).toBeFalse()
38 | })
39 |
40 | test("should return invalidIpDenyList: false after updating", async () => {
41 | await updateIpDenyList(redis, prefix, 8);
42 | const { deniedValue, invalidIpDenyList } = await checkDenyList(
43 | redis, prefix, ["whale", "albatros"]
44 | )
45 |
46 | expect(typeof deniedValue).toBe("undefined")
47 | expect(invalidIpDenyList).toBeFalse()
48 | })
49 |
50 | test("should return invalidIpDenyList: false after updating + disabling", async () => {
51 |
52 | // initial values
53 | expect(await redis.ttl(statusKey)).toBe(-2)
54 | const initialStatus = await redis.get(statusKey)
55 | expect(initialStatus).toBe(null)
56 |
57 | // UPDATE
58 | await updateIpDenyList(redis, prefix, 8);
59 | const { deniedValue, invalidIpDenyList } = await checkDenyList(
60 | redis, prefix, ["user"]
61 | )
62 |
63 | expect(typeof deniedValue).toBe("undefined")
64 | expect(invalidIpDenyList).toBeFalse()
65 | // positive tll on the status key
66 | expect(await redis.ttl(statusKey)).toBeGreaterThan(0)
67 | const status = await redis.get(statusKey)
68 | expect(status).toBe("valid")
69 |
70 | // DISABLE
71 | await disableIpDenyList(redis, prefix);
72 | const {
73 | deniedValue: secondDeniedValue,
74 | invalidIpDenyList: secondInvalidIpDenyList
75 | } = await checkDenyList(
76 | redis, prefix, ["foo", "bar"]
77 | )
78 |
79 | expect(secondDeniedValue).toBe("bar")
80 | expect(secondInvalidIpDenyList).toBeFalse()
81 | // -1 in the status key
82 | expect(await redis.ttl(statusKey)).toBe(-1)
83 | const secondStatus = await redis.get(statusKey)
84 | expect(secondStatus).toBe("disabled")
85 | })
86 |
87 | test("should handle timeout correctly", async () => {
88 |
89 | await updateIpDenyList(redis, prefix, 8, 5000); // update with 5 seconds ttl on status flag
90 | const pipeline = redis.multi()
91 | pipeline.smembers(allDenyListsKey)
92 | pipeline.smembers(ipDenyListsKey)
93 | pipeline.get(statusKey)
94 | pipeline.ttl(statusKey)
95 |
96 | const [allValues, ipDenyListValues, status, statusTTL]: [string[], string[], string | null, number] = await pipeline.exec();
97 | expect(ipDenyListValues.length).toBeGreaterThan(0)
98 | expect(allValues.length).toBe(ipDenyListValues.length + 2) // + 2 for foo and bar
99 | expect(status).toBe("valid")
100 | expect(statusTTL).toBeGreaterThan(2) // ttl is more than 5 seconds
101 |
102 | // wait 6 seconds
103 | await new Promise((r) => setTimeout(r, 6000));
104 |
105 | const [newAllValues, newIpDenyListValues, newStatus, newStatusTTL]: [string[], string[], string | null, number] = await pipeline.exec();
106 |
107 | // deny lists remain as they are
108 | expect(newIpDenyListValues.length).toBeGreaterThan(0)
109 | expect(newAllValues.length).toBe(allValues.length)
110 | expect(newIpDenyListValues.length).toBe(ipDenyListValues.length)
111 |
112 | // status flag is gone
113 | expect(newStatus).toBe(null)
114 | expect(newStatusTTL).toBe(-2)
115 | }, { timeout: 10_000 })
116 |
117 | test("should overwrite disabled status with updateIpDenyList", async () => {
118 | await disableIpDenyList(redis, prefix);
119 |
120 | const pipeline = redis.multi()
121 | pipeline.smembers(allDenyListsKey)
122 | pipeline.smembers(ipDenyListsKey)
123 | pipeline.get(statusKey)
124 | pipeline.ttl(statusKey)
125 |
126 | const [allValues, ipDenyListValues, status, statusTTL]: [string[], string[], string | null, number] = await pipeline.exec();
127 | expect(ipDenyListValues.length).toBe(0)
128 | expect(allValues.length).toBe(2) // + 2 for foo and bar
129 | expect(status).toBe("disabled")
130 | expect(statusTTL).toBe(-1)
131 |
132 | // update status: called from UI or from SDK when status key expires
133 | await updateIpDenyList(redis, prefix, 8);
134 |
135 | const [newAllValues, newIpDenyListValues, newStatus, newStatusTTL]: [string[], string[], string | null, number] = await pipeline.exec();
136 |
137 | // deny lists remain as they are
138 | expect(newIpDenyListValues.length).toBeGreaterThan(0)
139 | expect(newAllValues.length).toBe(newIpDenyListValues.length + 2)
140 | expect(newStatus).toBe("valid")
141 | expect(newStatusTTL).toBeGreaterThan(1000)
142 | })
143 |
144 | test("should be able to use ratelimit with deny list", async () => {
145 |
146 | /**
147 | * When enableProtection is set to true, there was an error in the
148 | * @upstash/ratelimit 2.0.3 release. Because the @upstash/redis
149 | * uses auto-pipelining by default, the client would send the
150 | * EVALSHA and the protection EVAL at the same time.
151 | *
152 | * When the db loses its scripts after a SCRIPT FLUSH or when
153 | * ratelimit is used in a new DB, the EVALSHA will fail. But
154 | * because since the EVAL and EVALSHA are pipelined, they will
155 | * both fail and EVAL will get the EVALSHA's error.
156 | */
157 | const redis = Redis.fromEnv({ enableAutoPipelining: true });
158 |
159 | // flush the db to make sure
160 | await redis.scriptFlush()
161 | // sleep for two secs
162 | await new Promise(r => setTimeout(r, 2000));
163 |
164 | const ratelimit = new Ratelimit({
165 | limiter: Ratelimit.fixedWindow(2, "1s"),
166 | redis,
167 | enableProtection: true
168 | })
169 |
170 | const { success, remaining } = await ratelimit.limit("ip-deny-list")
171 | expect(success).toBeTrue()
172 | expect(remaining).toBe(1)
173 | })
174 | })
175 |
176 | describe("should only allow threshold values from 1 to 8", async () => {
177 | const redis = Redis.fromEnv();
178 | const prefix = `test-ip-list-prefix`;
179 |
180 | test("should reject string", async () => {
181 | try {
182 | // @ts-expect-error test incorrect input
183 | await updateIpDenyList(redis, prefix, "test")
184 | } catch (error: any) {
185 | expect(error.name).toEqual("ThresholdError")
186 | }
187 | })
188 |
189 | test("should reject 0", async () => {
190 | try {
191 | await updateIpDenyList(redis, prefix, 0)
192 | } catch (error: any) {
193 | expect(error.name).toEqual("ThresholdError")
194 | }
195 | })
196 |
197 | test("should reject negative", async () => {
198 | try {
199 | await updateIpDenyList(redis, prefix, -1)
200 | } catch (error: any) {
201 | expect(error.name).toEqual("ThresholdError")
202 | }
203 | })
204 |
205 | test("should reject 9", async () => {
206 | try {
207 | await updateIpDenyList(redis, prefix, 9)
208 | } catch (error: any) {
209 | expect(error.name).toEqual("ThresholdError")
210 | }
211 | })
212 | })
213 |
--------------------------------------------------------------------------------
/src/deny-list/ip-deny-list.ts:
--------------------------------------------------------------------------------
1 | import type { Redis } from "../types";
2 | import { DenyListExtension, IpDenyListKey, IpDenyListStatusKey } from "../types"
3 | import { getIpListTTL } from "./time"
4 |
5 | const baseUrl = "https://raw.githubusercontent.com/stamparm/ipsum/master/levels"
6 |
7 | export class ThresholdError extends Error {
8 | constructor(threshold: number) {
9 | super(`Allowed threshold values are from 1 to 8, 1 and 8 included. Received: ${threshold}`);
10 | this.name = "ThresholdError";
11 | }
12 | }
13 |
14 | /**
15 | * Fetches the ips from the ipsum.txt at github
16 | *
17 | * In the repo we are using, 30+ ip lists are aggregated. The results are
18 | * stores in text files from 1 to 8.
19 | * https://github.com/stamparm/ipsum/tree/master/levels
20 | *
21 | * X.txt file holds ips which are in at least X of the lists.
22 | *
23 | * @param threshold ips with less than or equal to the threshold are not included
24 | * @returns list of ips
25 | */
26 | const getIpDenyList = async (threshold: number) => {
27 | if (typeof threshold !== "number" || threshold < 1 || threshold > 8) {
28 | throw new ThresholdError(threshold)
29 | }
30 |
31 | try {
32 | // Fetch data from the URL
33 | const response = await fetch(`${baseUrl}/${threshold}.txt`)
34 | if (!response.ok) {
35 | throw new Error(`Error fetching data: ${response.statusText}`)
36 | }
37 | const data = await response.text()
38 |
39 | // Process the data
40 | const lines = data.split("\n")
41 | return lines.filter((value) => value.length > 0) // remove empty values
42 | } catch (error) {
43 | throw new Error(`Failed to fetch ip deny list: ${error}`)
44 | }
45 | }
46 |
47 | /**
48 | * Gets the list of ips from the github source which are not in the
49 | * deny list already
50 | *
51 | * First, gets the ip list from github using the threshold. Then, calls redis with
52 | * a transaction which does the following:
53 | * - subtract the current ip deny list from all
54 | * - delete current ip deny list
55 | * - recreate ip deny list with the ips from github. Ips already in the users own lists
56 | * are excluded.
57 | * - status key is set to valid with ttl until next 2 AM UTC, which is a bit later than
58 | * when the list is updated on github.
59 | *
60 | * @param redis redis instance
61 | * @param prefix ratelimit prefix
62 | * @param threshold ips with less than or equal to the threshold are not included
63 | * @param ttl time to live in milliseconds for the status flag. Optional. If not
64 | * passed, ttl is infferred from current time.
65 | * @returns list of ips which are not in the deny list
66 | */
67 | export const updateIpDenyList = async (
68 | redis: Redis,
69 | prefix: string,
70 | threshold: number,
71 | ttl?: number
72 | ) => {
73 | const allIps = await getIpDenyList(threshold)
74 |
75 | const allDenyLists = [prefix, DenyListExtension, "all"].join(":")
76 | const ipDenyList = [prefix, DenyListExtension, IpDenyListKey].join(":")
77 | const statusKey = [prefix, IpDenyListStatusKey].join(":")
78 |
79 | const transaction = redis.multi()
80 |
81 | // remove the old ip deny list from the all set
82 | transaction.sdiffstore(allDenyLists, allDenyLists, ipDenyList)
83 |
84 | // delete the old ip deny list and create new one
85 | transaction.del(ipDenyList)
86 |
87 | transaction.sadd(ipDenyList, allIps.at(0), ...allIps.slice(1))
88 |
89 | // make all deny list and ip deny list disjoint by removing duplicate
90 | // ones from ip deny list
91 | transaction.sdiffstore(ipDenyList, ipDenyList, allDenyLists)
92 |
93 | // add remaining ips to all list
94 | transaction.sunionstore(allDenyLists, allDenyLists, ipDenyList)
95 |
96 | // set status key with ttl
97 | transaction.set(statusKey, "valid", {px: ttl ?? getIpListTTL()})
98 |
99 | return await transaction.exec()
100 | }
101 |
102 | /**
103 | * Disables the ip deny list by removing the ip deny list from the all
104 | * set and removing the ip deny list. Also sets the status key to disabled
105 | * with no ttl.
106 | *
107 | * @param redis redis instance
108 | * @param prefix ratelimit prefix
109 | * @returns
110 | */
111 | export const disableIpDenyList = async (redis: Redis, prefix: string) => {
112 | const allDenyListsKey = [prefix, DenyListExtension, "all"].join(":")
113 | const ipDenyListKey = [prefix, DenyListExtension, IpDenyListKey].join(":")
114 | const statusKey = [prefix, IpDenyListStatusKey].join(":")
115 |
116 | const transaction = redis.multi()
117 |
118 | // remove the old ip deny list from the all set
119 | transaction.sdiffstore(allDenyListsKey, allDenyListsKey, ipDenyListKey)
120 |
121 | // delete the old ip deny list
122 | transaction.del(ipDenyListKey)
123 |
124 | // set to disabled
125 | // this way, the TTL command in checkDenyListScript will return -1.
126 | transaction.set(statusKey, "disabled")
127 |
128 | return await transaction.exec()
129 | }
130 |
--------------------------------------------------------------------------------
/src/deny-list/scripts.test.ts:
--------------------------------------------------------------------------------
1 | import { Redis } from "@upstash/redis";
2 | import { beforeEach, describe, expect, test } from "bun:test";
3 | import type { IsDenied } from "../types";
4 | import { DenyListExtension, IpDenyListStatusKey } from "../types";
5 | import { checkDenyListScript } from "./scripts";
6 | import { disableIpDenyList, updateIpDenyList } from "./ip-deny-list";
7 |
8 | describe("should manage state correctly", async () => {
9 | const redis = Redis.fromEnv();
10 | const prefix = `test-script-prefix`;
11 |
12 | const allDenyListsKey = [prefix, DenyListExtension, "all"].join(":");
13 | const ipDenyListStatusKey = [prefix, IpDenyListStatusKey].join(":");
14 |
15 | beforeEach(async () => {
16 | await redis.flushdb()
17 | await redis.sadd(
18 | allDenyListsKey, "foo", "bar")
19 | });
20 |
21 | test("should return status: -2 initially", async () => {
22 | const [isMember, status] = await redis.eval(
23 | checkDenyListScript,
24 | [allDenyListsKey, ipDenyListStatusKey],
25 | ["whale", "foo", "bar", "zed"]
26 | ) as [IsDenied[], number];
27 |
28 | expect(isMember).toEqual([0, 1, 1, 0])
29 | expect(status).toBe(-2)
30 | })
31 |
32 | test("should return status: -1 when disabled", async () => {
33 | await disableIpDenyList(redis, prefix);
34 | const [isMember, status] = await redis.eval(
35 | checkDenyListScript,
36 | [allDenyListsKey, ipDenyListStatusKey],
37 | ["whale", "foo", "bar", "zed"]
38 | ) as [IsDenied[], number];
39 |
40 | expect(isMember).toEqual([0, 1, 1, 0])
41 | expect(status).toBe(-1)
42 | })
43 |
44 | test("should return status: number after update", async () => {
45 | await updateIpDenyList(redis, prefix, 8);
46 | const [isMember, status] = await redis.eval(
47 | checkDenyListScript,
48 | [allDenyListsKey, ipDenyListStatusKey],
49 | ["foo", "whale", "bar", "zed"]
50 | ) as [IsDenied[], number];
51 |
52 | expect(isMember).toEqual([1, 0, 1, 0])
53 | expect(status).toBeGreaterThan(1000)
54 | })
55 |
56 | test("should return status: -1 after update and disable", async () => {
57 | await updateIpDenyList(redis, prefix, 8);
58 | await disableIpDenyList(redis, prefix);
59 | const [isMember, status] = await redis.eval(
60 | checkDenyListScript,
61 | [allDenyListsKey, ipDenyListStatusKey],
62 | ["foo", "whale", "bar", "zed"]
63 | ) as [IsDenied[], number];
64 |
65 | expect(isMember).toEqual([1, 0, 1, 0])
66 | expect(status).toBe(-1)
67 | })
68 |
69 | test("should only make one of two consecutive requests update deny list", async () => {
70 |
71 | // running the eval script consecutively when the deny list needs
72 | // to be updated. Only one will update the ip list. It will be
73 | // given 30 seconds before its turn expires. Until then, other requests
74 | // will continue using the old ip deny list
75 | const response = await Promise.all([
76 | redis.eval(
77 | checkDenyListScript,
78 | [allDenyListsKey, ipDenyListStatusKey],
79 | ["foo", "whale", "bar", "zed"]
80 | ) as Promise<[IsDenied[], number]>,
81 | redis.eval(
82 | checkDenyListScript,
83 | [allDenyListsKey, ipDenyListStatusKey],
84 | ["foo", "whale", "bar", "zed"]
85 | ) as Promise<[IsDenied[], number]>
86 | ]);
87 |
88 | // first request is told that there is no valid ip list (ttl: -2),
89 | // hence it will update the ip deny list
90 | expect(response[0]).toEqual([[1, 0, 1, 0], -2])
91 |
92 | // second request is told that there is already a valid ip list
93 | // with ttl 30.
94 | expect(response[1]).toEqual([[1, 0, 1, 0], 30])
95 |
96 | const state = await redis.get(ipDenyListStatusKey)
97 | expect(state).toBe("pending")
98 | })
99 | })
--------------------------------------------------------------------------------
/src/deny-list/scripts.ts:
--------------------------------------------------------------------------------
1 | export const checkDenyListScript = `
2 | -- Checks if values provideed in ARGV are present in the deny lists.
3 | -- This is done using the allDenyListsKey below.
4 |
5 | -- Additionally, checks the status of the ip deny list using the
6 | -- ipDenyListStatusKey below. Here are the possible states of the
7 | -- ipDenyListStatusKey key:
8 | -- * status == -1: set to "disabled" with no TTL
9 | -- * status == -2: not set, meaning that is was set before but expired
10 | -- * status > 0: set to "valid", with a TTL
11 | --
12 | -- In the case of status == -2, we set the status to "pending" with
13 | -- 30 second ttl. During this time, the process which got status == -2
14 | -- will update the ip deny list.
15 |
16 | local allDenyListsKey = KEYS[1]
17 | local ipDenyListStatusKey = KEYS[2]
18 |
19 | local results = redis.call('SMISMEMBER', allDenyListsKey, unpack(ARGV))
20 | local status = redis.call('TTL', ipDenyListStatusKey)
21 | if status == -2 then
22 | redis.call('SETEX', ipDenyListStatusKey, 30, "pending")
23 | end
24 |
25 | return { results, status }
26 | `
--------------------------------------------------------------------------------
/src/deny-list/time.test.ts:
--------------------------------------------------------------------------------
1 | import { getIpListTTL } from './time';
2 | import { describe, expect, test } from "bun:test";
3 |
4 | describe('getIpListTTL', () => {
5 | test('returns correct TTL when it is before 2 AM UTC', () => {
6 | const before2AM = Date.UTC(2024, 5, 12, 1, 0, 0); // June 12, 2024, 1:00 AM UTC
7 | const expectedTTL = 1 * 60 * 60 * 1000; // 1 hour in milliseconds
8 |
9 | expect(getIpListTTL(before2AM)).toBe(expectedTTL);
10 | });
11 |
12 | test('returns correct TTL when it is exactly 2 AM UTC', () => {
13 | const exactly2AM = Date.UTC(2024, 5, 12, 2, 0, 0); // June 12, 2024, 2:00 AM UTC
14 | const expectedTTL = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
15 |
16 | expect(getIpListTTL(exactly2AM)).toBe(expectedTTL);
17 | });
18 |
19 | test('returns correct TTL when it is after 2 AM UTC but before the next 2 AM UTC', () => {
20 | const after2AM = Date.UTC(2024, 5, 12, 3, 0, 0); // June 12, 2024, 3:00 AM UTC
21 | const expectedTTL = 23 * 60 * 60 * 1000; // 23 hours in milliseconds
22 |
23 | expect(getIpListTTL(after2AM)).toBe(expectedTTL);
24 | });
25 |
26 | test('returns correct TTL when it is much later in the day', () => {
27 | const laterInDay = Date.UTC(2024, 5, 12, 20, 0, 0); // June 12, 2024, 8:00 PM UTC
28 | const expectedTTL = 6 * 60 * 60 * 1000; // 6 hours in milliseconds
29 |
30 | expect(getIpListTTL(laterInDay)).toBe(expectedTTL);
31 | });
32 |
33 | test('returns correct TTL when it is exactly the next day', () => {
34 | const nextDay = Date.UTC(2024, 5, 13, 2, 0, 0); // June 13, 2024, 2:00 AM UTC
35 | const expectedTTL = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
36 |
37 | expect(getIpListTTL(nextDay)).toBe(expectedTTL);
38 | });
39 |
40 | test('returns correct TTL when no time is provided (uses current time)', () => {
41 | const now = Date.now();
42 | const expectedTTL = getIpListTTL(now);
43 |
44 | expect(getIpListTTL()).toBe(expectedTTL);
45 | });
46 | });
47 |
--------------------------------------------------------------------------------
/src/deny-list/time.ts:
--------------------------------------------------------------------------------
1 |
2 | // Number of milliseconds in one hour
3 | const MILLISECONDS_IN_HOUR = 60 * 60 * 1000;
4 |
5 | // Number of milliseconds in one day
6 | const MILLISECONDS_IN_DAY = 24 * MILLISECONDS_IN_HOUR;
7 |
8 | // Number of milliseconds from the current time to 2 AM UTC
9 | const MILLISECONDS_TO_2AM = 2 * MILLISECONDS_IN_HOUR;
10 |
11 | export const getIpListTTL = (time?: number) => {
12 | const now = time || Date.now();
13 |
14 | // Time since the last 2 AM UTC
15 | const timeSinceLast2AM = (now - MILLISECONDS_TO_2AM) % MILLISECONDS_IN_DAY;
16 |
17 | // Remaining time until the next 2 AM UTC
18 | return MILLISECONDS_IN_DAY - timeSinceLast2AM;
19 | }
20 |
--------------------------------------------------------------------------------
/src/duration.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, it } from "bun:test";
2 | import { ms } from "./duration";
3 |
4 | describe("ms", () => {
5 | it("should return the correct number of milliseconds for a given duration", () => {
6 | expect(ms("100ms")).toBe(100);
7 | expect(ms("2s")).toBe(2000);
8 | expect(ms("3m")).toBe(180_000);
9 | expect(ms("4h")).toBe(14_400_000);
10 | expect(ms("5d")).toBe(432_000_000);
11 | expect(ms("10ms")).toBe(10);
12 | });
13 | describe("with space", () => {
14 | it("should return the correct number of milliseconds for a given duration", () => {
15 | expect(ms("100 ms")).toBe(100);
16 | expect(ms("2 s")).toBe(2000);
17 | expect(ms("3 m")).toBe(180_000);
18 | expect(ms("4 h")).toBe(14_400_000);
19 | expect(ms("5 d")).toBe(432_000_000);
20 | expect(ms("10 ms")).toBe(10);
21 | });
22 | });
23 | });
24 |
--------------------------------------------------------------------------------
/src/duration.ts:
--------------------------------------------------------------------------------
1 | type Unit = "ms" | "s" | "m" | "h" | "d";
2 | export type Duration = `${number} ${Unit}` | `${number}${Unit}`;
3 |
4 | /**
5 | * Convert a human readable duration to milliseconds
6 | */
7 | export function ms(d: Duration): number {
8 | const match = d.match(/^(\d+)\s?(ms|s|m|h|d)$/);
9 | if (!match) {
10 | throw new Error(`Unable to parse window size: ${d}`);
11 | }
12 | const time = Number.parseInt(match[1]);
13 | const unit = match[2] as Unit;
14 |
15 | switch (unit) {
16 | case "ms": {
17 | return time;
18 | }
19 | case "s": {
20 | return time * 1000;
21 | }
22 | case "m": {
23 | return time * 1000 * 60;
24 | }
25 | case "h": {
26 | return time * 1000 * 60 * 60;
27 | }
28 | case "d": {
29 | return time * 1000 * 60 * 60 * 24;
30 | }
31 |
32 | default: {
33 | throw new Error(`Unable to parse window size: ${d}`);
34 | }
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/getRemainingTokens.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, test } from "bun:test";
2 | import { Redis } from "@upstash/redis";
3 | import { MultiRegionRatelimit } from "./multi";
4 | import type { Ratelimit } from "./ratelimit";
5 | import { RegionRatelimit } from "./single";
6 | import type { Algorithm, Context, MultiRegionContext, RegionContext } from "./types";
7 |
8 | const limit = 10;
9 | const refillRate = 10;
10 | const windowString = "30s";
11 |
12 | function run(builder: Ratelimit) {
13 |
14 | describe("getRemainingTokens", () => {
15 | test("get remaining tokens", async () => {
16 | const id = crypto.randomUUID();
17 | // Stop at any random request call within the limit
18 | const stopAt = Math.floor(Math.random() * (limit - 1) + 1);
19 | for (let i = 1; i <= limit; i++) {
20 |
21 | const [limitResult, remainigResult] = await Promise.all([
22 | builder.limit(id),
23 | builder.getRemaining(id)
24 | ])
25 |
26 | expect(limitResult.remaining).toBe(remainigResult.remaining)
27 | expect(limitResult.reset).toBe(remainigResult.reset)
28 | if (i == stopAt) {
29 | break
30 | }
31 | }
32 |
33 | const { remaining } = await builder.getRemaining(id);
34 | expect(remaining).toBe(limit - stopAt);
35 | }, {
36 | timeout: 10_000,
37 | retry: 3
38 | });
39 | });
40 | }
41 |
42 | function newRegion(limiter: Algorithm): Ratelimit {
43 | return new RegionRatelimit({
44 | prefix: crypto.randomUUID(),
45 | redis: Redis.fromEnv({ enableAutoPipelining: true }),
46 | limiter,
47 | });
48 | }
49 |
50 | function newMultiRegion(limiter: Algorithm): Ratelimit {
51 | // eslint-disable-next-line unicorn/consistent-function-scoping
52 | function ensureEnv(key: string): string {
53 | const value = process.env[key];
54 | if (!value) {
55 | throw new Error(`Environment variable ${key} not found`);
56 | }
57 | return value;
58 | }
59 |
60 | return new MultiRegionRatelimit({
61 | prefix: crypto.randomUUID(),
62 | redis: [
63 | new Redis({
64 | url: ensureEnv("EU2_UPSTASH_REDIS_REST_URL"),
65 | token: ensureEnv("EU2_UPSTASH_REDIS_REST_TOKEN"),
66 | enableAutoPipelining: true
67 | }),
68 | new Redis({
69 | url: ensureEnv("APN_UPSTASH_REDIS_REST_URL"),
70 | token: ensureEnv("APN_UPSTASH_REDIS_REST_TOKEN"),
71 | enableAutoPipelining: true
72 | }),
73 | new Redis({
74 | url: ensureEnv("US1_UPSTASH_REDIS_REST_URL"),
75 | token: ensureEnv("US1_UPSTASH_REDIS_REST_TOKEN"),
76 | enableAutoPipelining: true
77 | }),
78 | ],
79 | limiter,
80 | });
81 | }
82 |
83 | describe("fixedWindow", () => {
84 | describe("region", () => run(newRegion(RegionRatelimit.fixedWindow(limit, windowString))));
85 |
86 | describe("multiRegion", () =>
87 | run(newMultiRegion(MultiRegionRatelimit.fixedWindow(limit, windowString))));
88 | });
89 | describe("slidingWindow", () => {
90 | describe("region", () => run(newRegion(RegionRatelimit.slidingWindow(limit, windowString))));
91 | describe("multiRegion", () =>
92 | run(newMultiRegion(MultiRegionRatelimit.slidingWindow(limit, windowString))));
93 | });
94 |
95 | describe("tokenBucket", () => {
96 | describe("region", () =>
97 | run(newRegion(RegionRatelimit.tokenBucket(refillRate, windowString, limit))));
98 | });
99 |
100 | describe("cachedFixedWindow", () => {
101 | describe("region", () => run(newRegion(RegionRatelimit.cachedFixedWindow(limit, windowString))));
102 | });
103 |
--------------------------------------------------------------------------------
/src/hash.test.ts:
--------------------------------------------------------------------------------
1 | import { Redis } from "@upstash/redis";
2 | import { describe, test } from "bun:test";
3 | import { safeEval } from "./hash";
4 | import { SCRIPTS } from "./lua-scripts/hash";
5 |
6 | const redis = Redis.fromEnv();
7 |
8 | describe("should set hash correctly", () => {
9 | test("should set hash in new db correctly", async () => {
10 | await redis.scriptFlush()
11 |
12 | // sleep for two secs
13 | await new Promise(r => setTimeout(r, 2000));
14 |
15 | await safeEval(
16 | {
17 | redis
18 | },
19 | SCRIPTS.singleRegion.fixedWindow.limit,
20 | ["id"],
21 | [10, 1]
22 | )
23 | })
24 | })
--------------------------------------------------------------------------------
/src/hash.ts:
--------------------------------------------------------------------------------
1 | import type { ScriptInfo } from "./lua-scripts/hash";
2 | import type { RegionContext } from "./types";
3 |
4 | /**
5 | * Runs the specified script with EVALSHA using the scriptHash parameter.
6 | *
7 | * If the EVALSHA fails, loads the script to redis and runs again with the
8 | * hash returned from Redis.
9 | *
10 | * @param ctx Regional or multi region context
11 | * @param script ScriptInfo of script to run. Contains the script and its hash
12 | * @param keys eval keys
13 | * @param args eval args
14 | */
15 | export const safeEval = async (
16 | ctx: RegionContext,
17 | script: ScriptInfo,
18 | keys: any[],
19 | args: any[],
20 | ) => {
21 | try {
22 | return await ctx.redis.evalsha(script.hash, keys, args)
23 | } catch (error) {
24 | if (`${error}`.includes("NOSCRIPT")) {
25 | const hash = await ctx.redis.scriptLoad(script.script)
26 |
27 | if (hash !== script.hash) {
28 | console.warn(
29 | "Upstash Ratelimit: Expected hash and the hash received from Redis"
30 | + " are different. Ratelimit will work as usual but performance will"
31 | + " be reduced."
32 | );
33 | }
34 |
35 | return await ctx.redis.evalsha(hash, keys, args)
36 | }
37 | throw error;
38 | }
39 | }
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | export {Analytics, type AnalyticsConfig} from "./analytics";
14 | export {MultiRegionRatelimit, type MultiRegionRatelimitConfig} from "./multi";
15 | export {RegionRatelimit as Ratelimit, type RegionRatelimitConfig as RatelimitConfig} from "./single";
16 | export {type Algorithm} from "./types";
17 | export * as IpDenyList from "./deny-list/ip-deny-list";
18 | export {type Duration} from "./duration";
--------------------------------------------------------------------------------
/src/lua-scripts/hash.test.ts:
--------------------------------------------------------------------------------
1 | import { Redis } from "@upstash/redis";
2 | import { describe, expect, test } from "bun:test";
3 | import { RESET_SCRIPT, SCRIPTS } from "./hash";
4 |
5 | describe("should use correct hash for lua scripts", () => {
6 | const redis = Redis.fromEnv();
7 |
8 | const validateHash = async (script: string, expectedHash: string) => {
9 | const hash = await redis.scriptLoad(script)
10 | expect(hash).toBe(expectedHash)
11 | }
12 |
13 | const algorithms = [
14 | ...Object.entries(SCRIPTS.singleRegion), ...Object.entries(SCRIPTS.multiRegion)
15 | ]
16 |
17 | // for each algorithm (fixedWindow, slidingWindow etc)
18 | for (const [algorithm, scripts] of algorithms) {
19 | describe(`${algorithm}`, () => {
20 | // for each method (limit & getRemaining)
21 | for (const [method, scriptInfo] of Object.entries(scripts)) {
22 | test(method, async () => {
23 | await validateHash(scriptInfo.script, scriptInfo.hash)
24 | })
25 | }
26 | })
27 | }
28 |
29 | test("reset script", async () => {
30 | await validateHash(RESET_SCRIPT.script, RESET_SCRIPT.hash)
31 | })
32 | })
--------------------------------------------------------------------------------
/src/lua-scripts/hash.ts:
--------------------------------------------------------------------------------
1 | import * as Single from "./single"
2 | import * as Multi from "./multi"
3 | import { resetScript } from "./reset"
4 |
5 | export type ScriptInfo = {
6 | script: string,
7 | hash: string
8 | }
9 |
10 | type Algorithm = {
11 | limit: ScriptInfo,
12 | getRemaining: ScriptInfo,
13 | }
14 |
15 | type AlgorithmKind =
16 | | "fixedWindow"
17 | | "slidingWindow"
18 | | "tokenBucket"
19 | | "cachedFixedWindow"
20 |
21 | export const SCRIPTS: {
22 | singleRegion: Record,
23 | multiRegion: Record, Algorithm>,
24 | } = {
25 | singleRegion: {
26 | fixedWindow: {
27 | limit: {
28 | script: Single.fixedWindowLimitScript,
29 | hash: "b13943e359636db027ad280f1def143f02158c13"
30 | },
31 | getRemaining: {
32 | script: Single.fixedWindowRemainingTokensScript,
33 | hash: "8c4c341934502aee132643ffbe58ead3450e5208"
34 | },
35 | },
36 | slidingWindow: {
37 | limit: {
38 | script: Single.slidingWindowLimitScript,
39 | hash: "e1391e429b699c780eb0480350cd5b7280fd9213"
40 | },
41 | getRemaining: {
42 | script: Single.slidingWindowRemainingTokensScript,
43 | hash: "65a73ac5a05bf9712903bc304b77268980c1c417"
44 | },
45 | },
46 | tokenBucket: {
47 | limit: {
48 | script: Single.tokenBucketLimitScript,
49 | hash: "5bece90aeef8189a8cfd28995b479529e270b3c6"
50 | },
51 | getRemaining: {
52 | script: Single.tokenBucketRemainingTokensScript,
53 | hash: "a15be2bb1db2a15f7c82db06146f9d08983900d0"
54 | },
55 | },
56 | cachedFixedWindow: {
57 | limit: {
58 | script: Single.cachedFixedWindowLimitScript,
59 | hash: "c26b12703dd137939b9a69a3a9b18e906a2d940f"
60 | },
61 | getRemaining: {
62 | script: Single.cachedFixedWindowRemainingTokenScript,
63 | hash: "8e8f222ccae68b595ee6e3f3bf2199629a62b91a"
64 | },
65 | }
66 | },
67 | multiRegion: {
68 | fixedWindow: {
69 | limit: {
70 | script: Multi.fixedWindowLimitScript,
71 | hash: "a8c14f3835aa87bd70e5e2116081b81664abcf5c"
72 | },
73 | getRemaining: {
74 | script: Multi.fixedWindowRemainingTokensScript,
75 | hash: "8ab8322d0ed5fe5ac8eb08f0c2e4557f1b4816fd"
76 | },
77 | },
78 | slidingWindow: {
79 | limit: {
80 | script: Multi.slidingWindowLimitScript,
81 | hash: "cb4fdc2575056df7c6d422764df0de3a08d6753b"
82 | },
83 | getRemaining: {
84 | script: Multi.slidingWindowRemainingTokensScript,
85 | hash: "558c9306b7ec54abb50747fe0b17e5d44bd24868"
86 | },
87 | },
88 | }
89 | }
90 |
91 | /** COMMON */
92 | export const RESET_SCRIPT: ScriptInfo = {
93 | script: resetScript,
94 | hash: "54bd274ddc59fb3be0f42deee2f64322a10e2b50"
95 | }
--------------------------------------------------------------------------------
/src/lua-scripts/multi.ts:
--------------------------------------------------------------------------------
1 | export const fixedWindowLimitScript = `
2 | local key = KEYS[1]
3 | local id = ARGV[1]
4 | local window = ARGV[2]
5 | local incrementBy = tonumber(ARGV[3])
6 |
7 | redis.call("HSET", key, id, incrementBy)
8 | local fields = redis.call("HGETALL", key)
9 | if #fields == 2 and tonumber(fields[2])==incrementBy then
10 | -- The first time this key is set, and the value will be equal to incrementBy.
11 | -- So we only need the expire command once
12 | redis.call("PEXPIRE", key, window)
13 | end
14 |
15 | return fields
16 | `;
17 | export const fixedWindowRemainingTokensScript = `
18 | local key = KEYS[1]
19 | local tokens = 0
20 |
21 | local fields = redis.call("HGETALL", key)
22 |
23 | return fields
24 | `;
25 |
26 | export const slidingWindowLimitScript = `
27 | local currentKey = KEYS[1] -- identifier including prefixes
28 | local previousKey = KEYS[2] -- key of the previous bucket
29 | local tokens = tonumber(ARGV[1]) -- tokens per window
30 | local now = ARGV[2] -- current timestamp in milliseconds
31 | local window = ARGV[3] -- interval in milliseconds
32 | local requestId = ARGV[4] -- uuid for this request
33 | local incrementBy = tonumber(ARGV[5]) -- custom rate, default is 1
34 |
35 | local currentFields = redis.call("HGETALL", currentKey)
36 | local requestsInCurrentWindow = 0
37 | for i = 2, #currentFields, 2 do
38 | requestsInCurrentWindow = requestsInCurrentWindow + tonumber(currentFields[i])
39 | end
40 |
41 | local previousFields = redis.call("HGETALL", previousKey)
42 | local requestsInPreviousWindow = 0
43 | for i = 2, #previousFields, 2 do
44 | requestsInPreviousWindow = requestsInPreviousWindow + tonumber(previousFields[i])
45 | end
46 |
47 | local percentageInCurrent = ( now % window) / window
48 | if requestsInPreviousWindow * (1 - percentageInCurrent ) + requestsInCurrentWindow >= tokens then
49 | return {currentFields, previousFields, false}
50 | end
51 |
52 | redis.call("HSET", currentKey, requestId, incrementBy)
53 |
54 | if requestsInCurrentWindow == 0 then
55 | -- The first time this key is set, the value will be equal to incrementBy.
56 | -- So we only need the expire command once
57 | redis.call("PEXPIRE", currentKey, window * 2 + 1000) -- Enough time to overlap with a new window + 1 second
58 | end
59 | return {currentFields, previousFields, true}
60 | `;
61 |
62 | export const slidingWindowRemainingTokensScript = `
63 | local currentKey = KEYS[1] -- identifier including prefixes
64 | local previousKey = KEYS[2] -- key of the previous bucket
65 | local now = ARGV[1] -- current timestamp in milliseconds
66 | local window = ARGV[2] -- interval in milliseconds
67 |
68 | local currentFields = redis.call("HGETALL", currentKey)
69 | local requestsInCurrentWindow = 0
70 | for i = 2, #currentFields, 2 do
71 | requestsInCurrentWindow = requestsInCurrentWindow + tonumber(currentFields[i])
72 | end
73 |
74 | local previousFields = redis.call("HGETALL", previousKey)
75 | local requestsInPreviousWindow = 0
76 | for i = 2, #previousFields, 2 do
77 | requestsInPreviousWindow = requestsInPreviousWindow + tonumber(previousFields[i])
78 | end
79 |
80 | local percentageInCurrent = ( now % window) / window
81 | requestsInPreviousWindow = math.floor(( 1 - percentageInCurrent ) * requestsInPreviousWindow)
82 |
83 | return requestsInCurrentWindow + requestsInPreviousWindow
84 | `;
85 |
--------------------------------------------------------------------------------
/src/lua-scripts/reset.ts:
--------------------------------------------------------------------------------
1 | export const resetScript = `
2 | local pattern = KEYS[1]
3 |
4 | -- Initialize cursor to start from 0
5 | local cursor = "0"
6 |
7 | repeat
8 | -- Scan for keys matching the pattern
9 | local scan_result = redis.call('SCAN', cursor, 'MATCH', pattern)
10 |
11 | -- Extract cursor for the next iteration
12 | cursor = scan_result[1]
13 |
14 | -- Extract keys from the scan result
15 | local keys = scan_result[2]
16 |
17 | for i=1, #keys do
18 | redis.call('DEL', keys[i])
19 | end
20 |
21 | -- Continue scanning until cursor is 0 (end of keyspace)
22 | until cursor == "0"
23 | `;
24 |
--------------------------------------------------------------------------------
/src/lua-scripts/single.ts:
--------------------------------------------------------------------------------
1 | export const fixedWindowLimitScript = `
2 | local key = KEYS[1]
3 | local window = ARGV[1]
4 | local incrementBy = ARGV[2] -- increment rate per request at a given value, default is 1
5 |
6 | local r = redis.call("INCRBY", key, incrementBy)
7 | if r == tonumber(incrementBy) then
8 | -- The first time this key is set, the value will be equal to incrementBy.
9 | -- So we only need the expire command once
10 | redis.call("PEXPIRE", key, window)
11 | end
12 |
13 | return r
14 | `;
15 |
16 | export const fixedWindowRemainingTokensScript = `
17 | local key = KEYS[1]
18 | local tokens = 0
19 |
20 | local value = redis.call('GET', key)
21 | if value then
22 | tokens = value
23 | end
24 | return tokens
25 | `;
26 |
27 | export const slidingWindowLimitScript = `
28 | local currentKey = KEYS[1] -- identifier including prefixes
29 | local previousKey = KEYS[2] -- key of the previous bucket
30 | local tokens = tonumber(ARGV[1]) -- tokens per window
31 | local now = ARGV[2] -- current timestamp in milliseconds
32 | local window = ARGV[3] -- interval in milliseconds
33 | local incrementBy = ARGV[4] -- increment rate per request at a given value, default is 1
34 |
35 | local requestsInCurrentWindow = redis.call("GET", currentKey)
36 | if requestsInCurrentWindow == false then
37 | requestsInCurrentWindow = 0
38 | end
39 |
40 | local requestsInPreviousWindow = redis.call("GET", previousKey)
41 | if requestsInPreviousWindow == false then
42 | requestsInPreviousWindow = 0
43 | end
44 | local percentageInCurrent = ( now % window ) / window
45 | -- weighted requests to consider from the previous window
46 | requestsInPreviousWindow = math.floor(( 1 - percentageInCurrent ) * requestsInPreviousWindow)
47 | if requestsInPreviousWindow + requestsInCurrentWindow >= tokens then
48 | return -1
49 | end
50 |
51 | local newValue = redis.call("INCRBY", currentKey, incrementBy)
52 | if newValue == tonumber(incrementBy) then
53 | -- The first time this key is set, the value will be equal to incrementBy.
54 | -- So we only need the expire command once
55 | redis.call("PEXPIRE", currentKey, window * 2 + 1000) -- Enough time to overlap with a new window + 1 second
56 | end
57 | return tokens - ( newValue + requestsInPreviousWindow )
58 | `;
59 |
60 | export const slidingWindowRemainingTokensScript = `
61 | local currentKey = KEYS[1] -- identifier including prefixes
62 | local previousKey = KEYS[2] -- key of the previous bucket
63 | local now = ARGV[1] -- current timestamp in milliseconds
64 | local window = ARGV[2] -- interval in milliseconds
65 |
66 | local requestsInCurrentWindow = redis.call("GET", currentKey)
67 | if requestsInCurrentWindow == false then
68 | requestsInCurrentWindow = 0
69 | end
70 |
71 | local requestsInPreviousWindow = redis.call("GET", previousKey)
72 | if requestsInPreviousWindow == false then
73 | requestsInPreviousWindow = 0
74 | end
75 |
76 | local percentageInCurrent = ( now % window ) / window
77 | -- weighted requests to consider from the previous window
78 | requestsInPreviousWindow = math.floor(( 1 - percentageInCurrent ) * requestsInPreviousWindow)
79 |
80 | return requestsInPreviousWindow + requestsInCurrentWindow
81 | `;
82 |
83 | export const tokenBucketLimitScript = `
84 | local key = KEYS[1] -- identifier including prefixes
85 | local maxTokens = tonumber(ARGV[1]) -- maximum number of tokens
86 | local interval = tonumber(ARGV[2]) -- size of the window in milliseconds
87 | local refillRate = tonumber(ARGV[3]) -- how many tokens are refilled after each interval
88 | local now = tonumber(ARGV[4]) -- current timestamp in milliseconds
89 | local incrementBy = tonumber(ARGV[5]) -- how many tokens to consume, default is 1
90 |
91 | local bucket = redis.call("HMGET", key, "refilledAt", "tokens")
92 |
93 | local refilledAt
94 | local tokens
95 |
96 | if bucket[1] == false then
97 | refilledAt = now
98 | tokens = maxTokens
99 | else
100 | refilledAt = tonumber(bucket[1])
101 | tokens = tonumber(bucket[2])
102 | end
103 |
104 | if now >= refilledAt + interval then
105 | local numRefills = math.floor((now - refilledAt) / interval)
106 | tokens = math.min(maxTokens, tokens + numRefills * refillRate)
107 |
108 | refilledAt = refilledAt + numRefills * interval
109 | end
110 |
111 | if tokens == 0 then
112 | return {-1, refilledAt + interval}
113 | end
114 |
115 | local remaining = tokens - incrementBy
116 | local expireAt = math.ceil(((maxTokens - remaining) / refillRate)) * interval
117 |
118 | redis.call("HSET", key, "refilledAt", refilledAt, "tokens", remaining)
119 | redis.call("PEXPIRE", key, expireAt)
120 | return {remaining, refilledAt + interval}
121 | `;
122 |
123 | export const tokenBucketIdentifierNotFound = -1
124 |
125 | export const tokenBucketRemainingTokensScript = `
126 | local key = KEYS[1]
127 | local maxTokens = tonumber(ARGV[1])
128 |
129 | local bucket = redis.call("HMGET", key, "refilledAt", "tokens")
130 |
131 | if bucket[1] == false then
132 | return {maxTokens, ${tokenBucketIdentifierNotFound}}
133 | end
134 |
135 | return {tonumber(bucket[2]), tonumber(bucket[1])}
136 | `;
137 |
138 | export const cachedFixedWindowLimitScript = `
139 | local key = KEYS[1]
140 | local window = ARGV[1]
141 | local incrementBy = ARGV[2] -- increment rate per request at a given value, default is 1
142 |
143 | local r = redis.call("INCRBY", key, incrementBy)
144 | if r == incrementBy then
145 | -- The first time this key is set, the value will be equal to incrementBy.
146 | -- So we only need the expire command once
147 | redis.call("PEXPIRE", key, window)
148 | end
149 |
150 | return r
151 | `;
152 |
153 | export const cachedFixedWindowRemainingTokenScript = `
154 | local key = KEYS[1]
155 | local tokens = 0
156 |
157 | local value = redis.call('GET', key)
158 | if value then
159 | tokens = value
160 | end
161 | return tokens
162 | `;
163 |
--------------------------------------------------------------------------------
/src/ratelimit.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, test } from "bun:test";
2 | import { log } from "node:console";
3 | import crypto from "node:crypto";
4 | import { Redis } from "@upstash/redis";
5 | import type { Algorithm } from ".";
6 | import type { Duration } from "./duration";
7 | import { MultiRegionRatelimit } from "./multi";
8 | import type { Ratelimit } from "./ratelimit";
9 | import { RegionRatelimit } from "./single";
10 | import { TestHarness } from "./test_utils";
11 | import type { Context, MultiRegionContext, RegionContext } from "./types";
12 |
13 | type TestCase = {
14 | // requests per second
15 | rps: number;
16 | /**
17 | * Multiplier for rate
18 | *
19 | * rate = 10, load = 0.5 -> attack rate will be 5
20 | */
21 | load: number;
22 | /**
23 | * rate at which the tokens will be added or consumed, default should be 1
24 | * @default 1
25 | */
26 | rate?: number;
27 | };
28 | const attackDuration = 10;
29 | const window = 5;
30 | const windowString: Duration = `${window} s`;
31 |
32 | const testcases: TestCase[] = [];
33 |
34 | for (const rps of [10, 100]) {
35 | for (const load of [0.5, 0.7]) {
36 | for (const rate of [undefined, 10]) {
37 | testcases.push({ load, rps, rate });
38 | }
39 | }
40 | }
41 |
42 | function run(builder: (tc: TestCase) => Ratelimit) {
43 | for (const tc of testcases) {
44 | const name = `${tc.rps.toString().padStart(4, " ")}/s - Load: ${(tc.load * 100)
45 | .toString()
46 | .padStart(3, " ")}% -> Sending ${(tc.rps * tc.load)
47 | .toString()
48 | .padStart(4, " ")}req/s at the rate of ${tc.rate ?? 1}`;
49 | const ratelimit = builder(tc);
50 |
51 | const limits = {
52 | lte: ((attackDuration * tc.rps * (tc.rate ?? 1)) / window) * 1.5,
53 | gte: ((attackDuration * tc.rps) / window) * 0.5,
54 | };
55 | describe(name, () => {
56 | test(
57 | `should be within ${limits.gte} - ${limits.lte}`,
58 | async () => {
59 | log(name);
60 | const harness = new TestHarness(ratelimit);
61 | await harness.attack(tc.rps * tc.load, attackDuration, tc.rate).catch((error) => {
62 | console.error(error);
63 | });
64 | log(
65 | "success:",
66 | harness.metrics.success,
67 | ", blocked:",
68 | harness.metrics.rejected,
69 | "out of:",
70 | harness.metrics.requests,
71 | );
72 |
73 | expect(harness.metrics.success).toBeLessThanOrEqual(limits.lte);
74 | expect(harness.metrics.success).toBeGreaterThanOrEqual(limits.gte);
75 | },
76 | attackDuration * 1000 * 4,
77 | );
78 | });
79 | }
80 | }
81 |
82 | function newMultiRegion(limiter: Algorithm): Ratelimit {
83 | // eslint-disable-next-line unicorn/consistent-function-scoping
84 | function ensureEnv(key: string): string {
85 | const value = process.env[key];
86 | if (!value) {
87 | throw new Error(`Environment variable ${key} not found`);
88 | }
89 | return value;
90 | }
91 |
92 | return new MultiRegionRatelimit({
93 | prefix: crypto.randomUUID(),
94 | redis: [
95 | new Redis({
96 | url: ensureEnv("EU2_UPSTASH_REDIS_REST_URL"),
97 | token: ensureEnv("EU2_UPSTASH_REDIS_REST_TOKEN"),
98 | }),
99 | new Redis({
100 | url: ensureEnv("APN_UPSTASH_REDIS_REST_URL"),
101 | token: ensureEnv("APN_UPSTASH_REDIS_REST_TOKEN"),
102 | }),
103 | new Redis({
104 | url: ensureEnv("US1_UPSTASH_REDIS_REST_URL"),
105 | token: ensureEnv("US1_UPSTASH_REDIS_REST_TOKEN"),
106 | }),
107 | ],
108 | limiter,
109 | });
110 | }
111 |
112 | function newRegion(limiter: Algorithm): Ratelimit {
113 | return new RegionRatelimit({
114 | prefix: crypto.randomUUID(),
115 | redis: Redis.fromEnv(),
116 | limiter,
117 | });
118 | }
119 |
120 | describe("timeout", () => {
121 | test("pass after timeout", async () => {
122 | const r = new RegionRatelimit({
123 | prefix: crypto.randomUUID(),
124 | redis: {
125 | ...Redis.fromEnv(),
126 | evalsha: () => new Promise((r) => setTimeout(r, 2000)),
127 | },
128 | limiter: RegionRatelimit.fixedWindow(1, "1 s"),
129 | timeout: 1000,
130 | });
131 | const start = Date.now();
132 | const res = await r.limit("id");
133 | const duration = Date.now() - start;
134 | expect(res.success).toBe(true);
135 | expect(res.limit).toBe(0);
136 | expect(res.remaining).toBe(0);
137 | expect(res.reset).toBe(0);
138 | expect(res.reason).toBe("timeout");
139 | expect(duration).toBeGreaterThanOrEqual(900);
140 | expect(duration).toBeLessThanOrEqual(1100);
141 |
142 | // stop the test from leaking
143 | await new Promise((r) => setTimeout(r, 5000));
144 | }, 10_000);
145 | });
146 |
147 | describe("fixedWindow", () => {
148 | describe("region", () =>
149 | run((tc) => newRegion(RegionRatelimit.fixedWindow(tc.rps * (tc.rate ?? 1), windowString))));
150 |
151 | describe("multiRegion", () =>
152 | run((tc) =>
153 | newMultiRegion(MultiRegionRatelimit.fixedWindow(tc.rps * (tc.rate ?? 1), windowString)),
154 | ));
155 | });
156 | describe("slidingWindow", () => {
157 | describe("region", () =>
158 | run((tc) => newRegion(RegionRatelimit.slidingWindow(tc.rps * (tc.rate ?? 1), windowString))));
159 | describe("multiRegion", () =>
160 | run((tc) =>
161 | newMultiRegion(MultiRegionRatelimit.slidingWindow(tc.rps * (tc.rate ?? 1), windowString)),
162 | ));
163 | });
164 |
165 | describe("tokenBucket", () => {
166 | describe("region", () =>
167 | run((tc) =>
168 | newRegion(RegionRatelimit.tokenBucket(tc.rps, windowString, tc.rps * (tc.rate ?? 1))),
169 | ));
170 | });
171 |
172 | describe("cachedFixedWindow", () => {
173 | describe("region", () =>
174 | run((tc) =>
175 | newRegion(RegionRatelimit.cachedFixedWindow(tc.rps * (tc.rate ?? 1), windowString)),
176 | ));
177 | });
178 |
--------------------------------------------------------------------------------
/src/resetUsedTokens.test.ts:
--------------------------------------------------------------------------------
1 | import { describe, expect, test } from "bun:test";
2 | import { Redis } from "@upstash/redis";
3 | import { MultiRegionRatelimit } from "./multi";
4 | import type { Ratelimit } from "./ratelimit";
5 | import { RegionRatelimit } from "./single";
6 | import type { Algorithm, Context, MultiRegionContext, RegionContext } from "./types";
7 |
8 | const limit = 10;
9 | const refillRate = 10;
10 | const windowString = "30s";
11 |
12 | function run(builder: Ratelimit) {
13 | const id = crypto.randomUUID();
14 |
15 | describe("resetUsedTokens", () => {
16 | test("reset the tokens", async () => {
17 | // Consume tokens until the remaining tokens are either equal to 2 or lesser than that
18 |
19 | const pendings: Promise[] = []
20 | for (let i = 0; i < 15; i++) {
21 | const { pending } = await builder.limit(id);
22 | pendings.push(pending)
23 | }
24 | await Promise.all(pendings)
25 |
26 | // reset tokens
27 | await builder.resetUsedTokens(id);
28 | const { remaining } = await builder.getRemaining(id);
29 | expect(remaining).toBe(limit);
30 | }, 10_000);
31 | });
32 | }
33 |
34 | function newRegion(limiter: Algorithm): Ratelimit {
35 | return new RegionRatelimit({
36 | prefix: crypto.randomUUID(),
37 | redis: Redis.fromEnv(),
38 | limiter,
39 | });
40 | }
41 |
42 | function newMultiRegion(limiter: Algorithm): Ratelimit {
43 | // eslint-disable-next-line unicorn/consistent-function-scoping
44 | function ensureEnv(key: string): string {
45 | const value = process.env[key];
46 | if (!value) {
47 | throw new Error(`Environment variable ${key} not found`);
48 | }
49 | return value;
50 | }
51 |
52 | return new MultiRegionRatelimit({
53 | prefix: crypto.randomUUID(),
54 | redis: [
55 | new Redis({
56 | url: ensureEnv("EU2_UPSTASH_REDIS_REST_URL"),
57 | token: ensureEnv("EU2_UPSTASH_REDIS_REST_TOKEN"),
58 | }),
59 | new Redis({
60 | url: ensureEnv("APN_UPSTASH_REDIS_REST_URL"),
61 | token: ensureEnv("APN_UPSTASH_REDIS_REST_TOKEN"),
62 | }),
63 | new Redis({
64 | url: ensureEnv("US1_UPSTASH_REDIS_REST_URL"),
65 | token: ensureEnv("US1_UPSTASH_REDIS_REST_TOKEN"),
66 | }),
67 | ],
68 | limiter,
69 | });
70 | }
71 |
72 | describe("fixedWindow", () => {
73 | describe("region", () => run(newRegion(RegionRatelimit.fixedWindow(limit, windowString))));
74 |
75 | describe("multiRegion", () =>
76 | run(newMultiRegion(MultiRegionRatelimit.fixedWindow(limit, windowString))));
77 | });
78 |
79 | describe("slidingWindow", () => {
80 | describe("region", () => run(newRegion(RegionRatelimit.slidingWindow(limit, windowString))));
81 | describe("multiRegion", () =>
82 | run(newMultiRegion(MultiRegionRatelimit.slidingWindow(limit, windowString))));
83 | });
84 |
85 | describe("tokenBucket", () => {
86 | describe("region", () =>
87 | run(newRegion(RegionRatelimit.tokenBucket(refillRate, windowString, limit))));
88 | });
89 |
90 | describe("cachedFixedWindow", () => {
91 | describe("region", () => run(newRegion(RegionRatelimit.cachedFixedWindow(limit, windowString))));
92 | });
93 |
--------------------------------------------------------------------------------
/src/test_utils.ts:
--------------------------------------------------------------------------------
1 | import crypto from "node:crypto";
2 | import type { Ratelimit } from "./ratelimit";
3 | import type { Context } from "./types";
4 |
5 | type Metrics = {
6 | requests: number;
7 | success: number;
8 | rejected: number;
9 | };
10 | export class TestHarness {
11 | /**
12 | * Used as prefix for redis keys
13 | */
14 | public readonly id: string;
15 |
16 | private readonly ratelimit: Ratelimit;
17 | public metrics: Metrics;
18 |
19 | public latencies: Record = {};
20 |
21 | constructor(ratelimit: Ratelimit) {
22 | this.ratelimit = ratelimit;
23 | this.id = crypto.randomUUID();
24 | this.metrics = {
25 | requests: 0,
26 | success: 0,
27 | rejected: 0,
28 | };
29 | }
30 |
31 | /**
32 | * @param rps - req per second
33 | * @param duration - duration in seconds
34 | */
35 | public async attack(rps: number, duration: number, rate?: number): Promise {
36 | const promises: Promise<{ success: boolean; pending: Promise }>[] = [];
37 |
38 | for (let i = 0; i < duration; i++) {
39 | for (let r = 0; r < rps; r++) {
40 | this.metrics.requests++;
41 | const id = crypto.randomUUID();
42 | this.latencies[id] = { start: Date.now(), end: -1 };
43 | promises.push(
44 | this.ratelimit.limit(this.id, { rate }).then((res) => {
45 | this.latencies[id].end = Date.now();
46 | return res;
47 | }),
48 | );
49 | await new Promise((r) => setTimeout(r, 500 / rps));
50 | }
51 | }
52 |
53 | await Promise.all(
54 | promises.map(async (p) => {
55 | const { success, pending } = await p;
56 | await pending;
57 | if (success) {
58 | this.metrics.success++;
59 | } else {
60 | this.metrics.rejected++;
61 | }
62 | }),
63 | );
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/tools/seed.ts:
--------------------------------------------------------------------------------
1 | import { Redis } from "@upstash/redis";
2 | import { Analytics } from "../analytics";
3 |
4 | const redis = Redis.fromEnv();
5 |
6 | const identifier = new Set();
7 |
8 | function getId(): string {
9 | if (identifier.size === 0 || Math.random() > 0.95) {
10 | const newIp = Array.from({ length: 4 })
11 | .fill(0)
12 | .map((_) => Math.floor(Math.random() * 256))
13 | .join(".");
14 | identifier.add(newIp);
15 | }
16 | return [...identifier][Math.floor(Math.random() * identifier.size)];
17 | }
18 |
19 | const a = new Analytics({ redis });
20 |
21 | async function main() {
22 | const now = Date.now();
23 | for (let i = 0; i < 1000; i++) {
24 | await Promise.all(
25 | Array.from({ length: 100 }).fill(0).map((_) =>
26 | a.record({
27 | time: now - Math.round(Math.random() * 7 * 24 * 60 * 60 * 1000),
28 | identifier: getId(),
29 | success: Math.random() > 0.2,
30 | }),
31 | ),
32 | );
33 | }
34 | }
35 |
36 | // eslint-disable-next-line unicorn/prefer-top-level-await
37 | main();
38 |
--------------------------------------------------------------------------------
/src/types.ts:
--------------------------------------------------------------------------------
1 | import type { Redis as RedisCore } from "@upstash/redis";
2 | import type { Geo } from "./analytics";
3 |
4 | /**
5 | * EphemeralCache is used to block certain identifiers right away in case they have already exceeded the ratelimit.
6 | */
7 | export type EphemeralCache = {
8 | isBlocked: (identifier: string) => { blocked: boolean; reset: number };
9 | blockUntil: (identifier: string, reset: number) => void;
10 |
11 | set: (key: string, value: number) => void;
12 | get: (key: string) => number | null;
13 |
14 | incr: (key: string) => number;
15 |
16 | pop: (key: string) => void;
17 | empty: () => void;
18 |
19 | size: () => number;
20 | }
21 |
22 | export type RegionContext = {
23 | redis: Redis;
24 | cache?: EphemeralCache,
25 | };
26 | export type MultiRegionContext = { regionContexts: Omit; cache?: EphemeralCache };
27 |
28 | export type RatelimitResponseType = "timeout" | "cacheBlock" | "denyList"
29 |
30 | export type Context = RegionContext | MultiRegionContext;
31 | export type RatelimitResponse = {
32 | /**
33 | * Whether the request may pass(true) or exceeded the limit(false)
34 | */
35 | success: boolean;
36 | /**
37 | * Maximum number of requests allowed within a window.
38 | */
39 | limit: number;
40 | /**
41 | * How many requests the user has left within the current window.
42 | */
43 | remaining: number;
44 | /**
45 | * Unix timestamp in milliseconds when the limits are reset.
46 | */
47 | reset: number;
48 |
49 | /**
50 | * For the MultiRegion setup we do some synchronizing in the background, after returning the current limit.
51 | * Or when analytics is enabled, we send the analytics asynchronously after returning the limit.
52 | * In most case you can simply ignore this.
53 | *
54 | * On Vercel Edge or Cloudflare workers, you need to explicitly handle the pending Promise like this:
55 | *
56 | * ```ts
57 | * const { pending } = await ratelimit.limit("id")
58 | * context.waitUntil(pending)
59 | * ```
60 | *
61 | * See `waitUntil` documentation in
62 | * [Cloudflare](https://developers.cloudflare.com/workers/runtime-apis/handlers/fetch/#contextwaituntil)
63 | * and [Vercel](https://vercel.com/docs/functions/edge-middleware/middleware-api#waituntil)
64 | * for more details.
65 | * ```
66 | */
67 | pending: Promise;
68 |
69 | /**
70 | * Reason behind the result in `success` field.
71 | * - Is set to "timeout" when request times out
72 | * - Is set to "cacheBlock" when an identifier is blocked through cache without calling redis because it was
73 | * rate limited previously.
74 | * - Is set to "denyList" when identifier or one of ip/user-agent/country parameters is in deny list. To enable
75 | * deny list, see `enableProtection` parameter. To edit the deny list, see the Upstash Ratelimit Dashboard
76 | * at https://console.upstash.com/ratelimit.
77 | * - Is set to undefined if rate limit check had to use Redis. This happens in cases when `success` field in
78 | * the response is true. It can also happen the first time sucecss is false.
79 | */
80 | reason?: RatelimitResponseType;
81 |
82 | /**
83 | * The value which was in the deny list if reason: "denyList"
84 | */
85 | deniedValue?: DeniedValue
86 | };
87 |
88 | export type Algorithm = () => {
89 | limit: (
90 | ctx: TContext,
91 | identifier: string,
92 | rate?: number,
93 | opts?: {
94 | cache?: EphemeralCache;
95 | },
96 | ) => Promise;
97 | getRemaining: (ctx: TContext, identifier: string) => Promise<{
98 | remaining: number,
99 | reset: number
100 | }>;
101 | resetTokens: (ctx: TContext, identifier: string) => Promise;
102 | };
103 |
104 | export type IsDenied = 0 | 1;
105 |
106 | export type DeniedValue = string | undefined;
107 | export type DenyListResponse = { deniedValue: DeniedValue, invalidIpDenyList: boolean }
108 |
109 | export const DenyListExtension = "denyList" as const
110 | export const IpDenyListKey = "ipDenyList" as const
111 | export const IpDenyListStatusKey = "ipDenyListStatus" as const
112 |
113 | export type LimitPayload = [RatelimitResponse, DenyListResponse];
114 | export type LimitOptions = {
115 | geo?: Geo,
116 | rate?: number,
117 | ip?: string,
118 | userAgent?: string,
119 | country?: string
120 | }
121 |
122 | export type Redis = RedisCore
123 |
--------------------------------------------------------------------------------
/tsup.config.js:
--------------------------------------------------------------------------------
1 | import { defineConfig } from "tsup";
2 |
3 | export default defineConfig({
4 | entry: ["src/index.ts"],
5 | format: ["cjs", "esm"],
6 | splitting: false,
7 | sourcemap: true,
8 | clean: true,
9 | bundle: true,
10 | dts: true,
11 | });
12 |
--------------------------------------------------------------------------------
/turbo.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://turbo.build/schema.json",
3 | "pipeline": {
4 | "build": {
5 | "dependsOn": ["^build"],
6 | "outputs": ["dist/**", ".next/**", ".nuxt/**", ".output/**"]
7 | },
8 | "dev": {
9 | "cache": false
10 | },
11 | "test": {
12 | "dependsOn": ["^build"],
13 | "cache": false
14 | }
15 | }
16 | }
17 |
--------------------------------------------------------------------------------